mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-11 13:23:00 +00:00
Feature/vegas scroll mode (#215)
* feat(display): add Vegas-style continuous scroll mode Implement an opt-in Vegas ticker mode that composes all enabled plugin content into a single continuous horizontal scroll. Includes a modular package (src/vegas_mode/) with double-buffered streaming, 125 FPS render pipeline using the existing ScrollHelper, live priority interruption support, and a web UI for configuration with drag-drop plugin ordering. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(vegas): add three-mode display system (SCROLL, FIXED_SEGMENT, STATIC) Adds a flexible display mode system for Vegas scroll mode that allows plugins to control how their content appears in the continuous scroll: - SCROLL: Content scrolls continuously (multi-item plugins like sports) - FIXED_SEGMENT: Fixed block that scrolls by (clock, weather) - STATIC: Scroll pauses, plugin displays, then resumes (alerts) Changes: - Add VegasDisplayMode enum to base_plugin.py with backward-compatible mapping from legacy get_vegas_content_type() - Add static pause handling to coordinator with scroll position save/restore - Add mode-aware content composition to stream_manager - Add vegas_mode info to /api/v3/plugins/installed endpoint - Add mode indicators to Vegas settings UI - Add comprehensive plugin developer documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas,widgets): address validation, thread safety, and XSS issues Vegas mode fixes: - config.py: align validation limits with UI (scroll_speed max 200, separator_width max 128) - coordinator.py: fix race condition by properly initializing _pending_config - plugin_adapter.py: remove unused import - render_pipeline.py: preserve deque type in reset() method - stream_manager.py: fix lock handling and swap_buffers to truly swap API fixes: - api_v3.py: normalize boolean checkbox values, validate numeric fields, ensure JSON arrays Widget fixes: - day-selector.js: remove escapeHtml from JSON.stringify to prevent corruption - password-input.js: use deterministic color class mapping for Tailwind JIT - radio-group.js: replace inline onchange with addEventListener to prevent XSS - select-dropdown.js: guard global registry access - slider.js: add escapeAttr for attributes, fix null dereference in setValue Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): improve exception handling and static pause state management coordinator.py: - _check_live_priority: use logger.exception for full traceback - _end_static_pause: guard scroll resume on interruption (stop/live priority) - _update_static_mode_plugins: log errors instead of silently swallowing render_pipeline.py: - compose_scroll_content: use specific exceptions and logger.exception - render_frame: use specific exceptions and logger.exception - hot_swap_content: use specific exceptions and logger.exception Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): add interrupt mechanism and improve config/exception handling - Add interrupt checker callback to Vegas coordinator for responsive handling of on-demand requests and wifi status during Vegas mode - Fix config.py update() to include dynamic duration fields - Fix is_plugin_included() consistency with get_ordered_plugins() - Update _apply_pending_config to propagate config to StreamManager - Change _fetch_plugin_content to use logger.exception for traceback - Replace bare except in _refresh_plugin_list with specific exceptions - Add aria-label accessibility to Vegas toggle checkbox - Fix XSS vulnerability in plugin metadata rendering with escapeHtml Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): improve logging, validation, lock handling, and config updates - display_controller.py: use logger.exception for Vegas errors with traceback - base_plugin.py: validate vegas_panel_count as positive integer with warning - coordinator.py: fix _apply_pending_config to avoid losing concurrent updates by clearing _pending_config while holding lock - plugin_adapter.py: remove broad catch-all, use narrower exception types (AttributeError, TypeError, ValueError, OSError, RuntimeError) and logger.exception for traceback preservation - api_v3.py: only update vegas_config['enabled'] when key is present in data to prevent incorrect disabling when checkbox is omitted Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): improve cycle advancement, logging, and accessibility - Add advance_cycle() method to StreamManager for clearing buffer between cycles - Call advance_cycle() in RenderPipeline.start_new_cycle() for fresh content - Use logger.exception() for interrupt check and static pause errors (full tracebacks) - Add id="vegas_scroll_label" to h3 for aria-labelledby reference - Call updatePluginConfig() after rendering plugin list for proper initialization Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): add thread-safety, preserve updates, and improve logging - display_controller.py: Use logger.exception() for Vegas import errors - plugin_adapter.py: Add thread-safe cache lock, remove unused exception binding - stream_manager.py: In-place merge in process_updates() preserves non-updated plugins - api_v3.py: Change vegas_scroll_enabled default from False to True Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): add debug logging and narrow exception types - stream_manager.py: Log when get_vegas_display_mode() is unavailable - stream_manager.py: Narrow exception type from Exception to (AttributeError, TypeError) - api_v3.py: Log exceptions when reading Vegas display metadata with plugin context Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): fix method call and improve exception logging - Fix _check_vegas_interrupt() calling nonexistent _check_wifi_status(), now correctly calls _check_wifi_status_message() - Update _refresh_plugin_list() exception handler to use logger.exception() with plugin_id and class name for remote debugging Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(web): replace complex toggle with standard checkbox for Vegas mode The Tailwind pseudo-element toggle (after:content-[''], etc.) wasn't rendering because these classes weren't in the CSS bundle. Replaced with a simple checkbox that matches other form controls in the template. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * debug(vegas): add detailed logging to _refresh_plugin_list Track why plugins aren't being found for Vegas scroll: - Log count of loaded plugins - Log enabled status for each plugin - Log content_type and display_mode checks - Log when plugin_manager lacks loaded_plugins Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): use correct attribute name for plugin manager StreamManager and VegasModeCoordinator were checking for plugin_manager.loaded_plugins but PluginManager stores active plugins in plugin_manager.plugins. This caused Vegas scroll to find zero plugins despite plugins being available. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): convert scroll_speed from px/sec to px/frame correctly The config scroll_speed is in pixels per second, but ScrollHelper in frame_based_scrolling mode interprets it as pixels per frame. Previously this caused the speed to be clamped to max 5.0 regardless of the configured value. Now properly converts: pixels_per_frame = scroll_speed * scroll_delay With defaults (50 px/s, 0.02s delay), this gives 1 px/frame = 50 px/s. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(vegas): add FPS logging every 5 seconds Logs actual FPS vs target FPS to help diagnose performance issues. Shows frame count in each 5-second interval. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): improve plugin content capture reliability - Call update_data() before capture to ensure fresh plugin data - Try display() without force_clear first, fallback if TypeError - Retry capture with force_clear=True if first attempt is blank - Use histogram-based blank detection instead of point sampling (more reliable for content positioned anywhere in frame) This should help capture content from plugins that don't implement get_vegas_content() natively. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): handle callable width/height on display_manager DisplayManager.width and .height may be methods or properties depending on the implementation. Use callable() check to call them if needed, ensuring display_width and display_height are always integers. Fixes potential TypeError when width/height are methods. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): use logger.exception for display mode errors Replace logger.error with logger.exception to capture full stack trace when get_vegas_display_mode() fails on a plugin. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): protect plugin list updates with buffer lock Move assignment of _ordered_plugins and index resets under _buffer_lock to prevent race conditions with _prefetch_content() which reads these variables under the same lock. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): catch all exceptions in get_vegas_display_mode Broaden exception handling from AttributeError/TypeError to Exception so any plugin error in get_vegas_display_mode() doesn't abort the entire plugin list refresh. The loop continues with the default FIXED_SEGMENT mode. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): refresh stream manager when config updates After updating stream_manager.config, force a refresh to pick up changes to plugin_order, excluded_plugins, and buffer_ahead settings. Also use logger.exception to capture full stack traces on config update errors. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * debug(vegas): add detailed logging for blank image detection * feat(vegas): extract full scroll content from plugins using ScrollHelper Plugins like ledmatrix-stocks and odds-ticker use ScrollHelper with a cached_image that contains their full scrolling content. Instead of falling back to single-frame capture, now check for scroll_helper.cached_image first to get the complete scrolling content for Vegas mode. * debug(vegas): add comprehensive INFO-level logging for plugin content flow - Log each plugin being processed with class name - Log which content methods are tried (native, scroll_helper, fallback) - Log success/failure of each method with image dimensions - Log brightness check results for blank image detection - Add visual separators in logs for easier debugging - Log plugin list refresh with enabled/excluded status Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(vegas): trigger scroll content generation when cache is empty When a plugin has a scroll_helper but its cached_image is not yet populated, try to trigger content generation by: 1. Calling _create_scrolling_display() if available (stocks pattern) 2. Calling display(force_clear=True) as a fallback This allows plugins like stocks to provide their full scroll content even when Vegas mode starts before the plugin has run its normal display cycle. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: improve exception handling in plugin_adapter scroll content retrieval Replace broad except Exception handlers with narrow exception types (AttributeError, TypeError, ValueError, OSError) and use logger.exception instead of logger.warning/info to capture full stack traces for better diagnosability. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: narrow exception handling in coordinator and plugin_adapter - coordinator.py: Replace broad Exception catch around get_vegas_display_mode() with (AttributeError, TypeError) and use logger.exception for stack traces - plugin_adapter.py: Narrow update_data() exception handler to (AttributeError, RuntimeError, OSError) and use logger.exception Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: improve Vegas mode robustness and API validation - display_controller: Guard against None plugin_manager in Vegas init - coordinator: Restore scrolling state in resume() to match pause() - api_v3: Validate Vegas numeric fields with range checks and 400 errors Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
554
src/vegas_mode/stream_manager.py
Normal file
554
src/vegas_mode/stream_manager.py
Normal file
@@ -0,0 +1,554 @@
|
||||
"""
|
||||
Stream Manager for Vegas Mode
|
||||
|
||||
Manages plugin content streaming with look-ahead buffering. Maintains a queue
|
||||
of plugin content that's ready to be rendered, prefetching 1-2 plugins ahead
|
||||
of the current scroll position.
|
||||
|
||||
Supports three display modes:
|
||||
- SCROLL: Continuous scrolling content
|
||||
- FIXED_SEGMENT: Fixed block that scrolls by
|
||||
- STATIC: Pause scroll to display (marked for coordinator handling)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from PIL import Image
|
||||
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContentSegment:
|
||||
"""Represents a segment of scrollable content from a plugin."""
|
||||
plugin_id: str
|
||||
images: List[Image.Image]
|
||||
total_width: int
|
||||
display_mode: VegasDisplayMode = field(default=VegasDisplayMode.FIXED_SEGMENT)
|
||||
fetched_at: float = field(default_factory=time.time)
|
||||
is_stale: bool = False
|
||||
|
||||
@property
|
||||
def image_count(self) -> int:
|
||||
return len(self.images)
|
||||
|
||||
@property
|
||||
def is_static(self) -> bool:
|
||||
"""Check if this segment should trigger a static pause."""
|
||||
return self.display_mode == VegasDisplayMode.STATIC
|
||||
|
||||
|
||||
class StreamManager:
|
||||
"""
|
||||
Manages streaming of plugin content for Vegas scroll mode.
|
||||
|
||||
Key responsibilities:
|
||||
- Maintain ordered list of plugins to stream
|
||||
- Prefetch content 1-2 plugins ahead of current position
|
||||
- Handle plugin data updates via double-buffer swap
|
||||
- Manage content lifecycle and staleness
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: VegasModeConfig,
|
||||
plugin_manager: 'PluginManager',
|
||||
plugin_adapter: PluginAdapter
|
||||
):
|
||||
"""
|
||||
Initialize the stream manager.
|
||||
|
||||
Args:
|
||||
config: Vegas mode configuration
|
||||
plugin_manager: Plugin manager for accessing plugins
|
||||
plugin_adapter: Adapter for getting plugin content
|
||||
"""
|
||||
self.config = config
|
||||
self.plugin_manager = plugin_manager
|
||||
self.plugin_adapter = plugin_adapter
|
||||
|
||||
# Content queue (double-buffered)
|
||||
self._active_buffer: Deque[ContentSegment] = deque()
|
||||
self._staging_buffer: Deque[ContentSegment] = deque()
|
||||
self._buffer_lock = threading.RLock() # RLock for reentrant access
|
||||
|
||||
# Plugin rotation state
|
||||
self._ordered_plugins: List[str] = []
|
||||
self._current_index: int = 0
|
||||
self._prefetch_index: int = 0
|
||||
|
||||
# Update tracking
|
||||
self._pending_updates: Dict[str, bool] = {}
|
||||
self._last_refresh: float = 0.0
|
||||
self._refresh_interval: float = 30.0 # Refresh plugin list every 30s
|
||||
|
||||
# Statistics
|
||||
self.stats = {
|
||||
'segments_fetched': 0,
|
||||
'segments_served': 0,
|
||||
'buffer_swaps': 0,
|
||||
'fetch_errors': 0,
|
||||
}
|
||||
|
||||
logger.info("StreamManager initialized with buffer_ahead=%d", config.buffer_ahead)
|
||||
|
||||
def initialize(self) -> bool:
|
||||
"""
|
||||
Initialize the stream manager with current plugin list.
|
||||
|
||||
Returns:
|
||||
True if initialized successfully with at least one plugin
|
||||
"""
|
||||
self._refresh_plugin_list()
|
||||
|
||||
if not self._ordered_plugins:
|
||||
logger.warning("No plugins available for Vegas scroll")
|
||||
return False
|
||||
|
||||
# Prefetch initial content
|
||||
self._prefetch_content(count=min(self.config.buffer_ahead + 1, len(self._ordered_plugins)))
|
||||
|
||||
logger.info(
|
||||
"StreamManager initialized with %d plugins, %d segments buffered",
|
||||
len(self._ordered_plugins), len(self._active_buffer)
|
||||
)
|
||||
return len(self._active_buffer) > 0
|
||||
|
||||
def get_next_segment(self) -> Optional[ContentSegment]:
|
||||
"""
|
||||
Get the next content segment for rendering.
|
||||
|
||||
Returns:
|
||||
ContentSegment or None if buffer is empty
|
||||
"""
|
||||
with self._buffer_lock:
|
||||
if not self._active_buffer:
|
||||
# Try to fetch more content
|
||||
self._prefetch_content(count=1)
|
||||
if not self._active_buffer:
|
||||
return None
|
||||
|
||||
segment = self._active_buffer.popleft()
|
||||
self.stats['segments_served'] += 1
|
||||
|
||||
# Trigger prefetch to maintain buffer
|
||||
self._ensure_buffer_filled()
|
||||
|
||||
return segment
|
||||
|
||||
def peek_next_segment(self) -> Optional[ContentSegment]:
|
||||
"""
|
||||
Peek at the next segment without removing it.
|
||||
|
||||
Returns:
|
||||
ContentSegment or None if buffer is empty
|
||||
"""
|
||||
with self._buffer_lock:
|
||||
if self._active_buffer:
|
||||
return self._active_buffer[0]
|
||||
return None
|
||||
|
||||
def get_buffer_status(self) -> Dict[str, Any]:
|
||||
"""Get current buffer status for monitoring."""
|
||||
with self._buffer_lock:
|
||||
return {
|
||||
'active_count': len(self._active_buffer),
|
||||
'staging_count': len(self._staging_buffer),
|
||||
'total_plugins': len(self._ordered_plugins),
|
||||
'current_index': self._current_index,
|
||||
'prefetch_index': self._prefetch_index,
|
||||
'stats': self.stats.copy(),
|
||||
}
|
||||
|
||||
def get_active_plugin_ids(self) -> List[str]:
|
||||
"""
|
||||
Get list of plugin IDs currently in the active buffer.
|
||||
|
||||
Thread-safe accessor for render pipeline.
|
||||
|
||||
Returns:
|
||||
List of plugin IDs in buffer order
|
||||
"""
|
||||
with self._buffer_lock:
|
||||
return [seg.plugin_id for seg in self._active_buffer]
|
||||
|
||||
def mark_plugin_updated(self, plugin_id: str) -> None:
|
||||
"""
|
||||
Mark a plugin as having updated data.
|
||||
|
||||
Called when a plugin's data changes. Triggers content refresh
|
||||
for that plugin in the staging buffer.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin that was updated
|
||||
"""
|
||||
with self._buffer_lock:
|
||||
self._pending_updates[plugin_id] = True
|
||||
|
||||
logger.debug("Plugin %s marked for update", plugin_id)
|
||||
|
||||
def process_updates(self) -> None:
|
||||
"""
|
||||
Process pending plugin updates.
|
||||
|
||||
Performs in-place update of segments in the active buffer,
|
||||
preserving non-updated plugins and their order.
|
||||
"""
|
||||
with self._buffer_lock:
|
||||
if not self._pending_updates:
|
||||
return
|
||||
|
||||
updated_plugins = list(self._pending_updates.keys())
|
||||
self._pending_updates.clear()
|
||||
|
||||
# Fetch fresh content for each updated plugin (outside lock for slow ops)
|
||||
refreshed_segments = {}
|
||||
for plugin_id in updated_plugins:
|
||||
self.plugin_adapter.invalidate_cache(plugin_id)
|
||||
segment = self._fetch_plugin_content(plugin_id)
|
||||
if segment:
|
||||
refreshed_segments[plugin_id] = segment
|
||||
|
||||
# In-place merge: replace segments in active buffer
|
||||
with self._buffer_lock:
|
||||
# Build new buffer preserving order, replacing updated segments
|
||||
new_buffer: Deque[ContentSegment] = deque()
|
||||
seen_plugins: set = set()
|
||||
|
||||
for segment in self._active_buffer:
|
||||
if segment.plugin_id in refreshed_segments:
|
||||
# Replace with refreshed segment (only once per plugin)
|
||||
if segment.plugin_id not in seen_plugins:
|
||||
new_buffer.append(refreshed_segments[segment.plugin_id])
|
||||
seen_plugins.add(segment.plugin_id)
|
||||
# Skip duplicate entries for same plugin
|
||||
else:
|
||||
# Keep non-updated segment
|
||||
new_buffer.append(segment)
|
||||
|
||||
self._active_buffer = new_buffer
|
||||
|
||||
logger.debug("Processed in-place updates for %d plugins", len(updated_plugins))
|
||||
|
||||
def swap_buffers(self) -> None:
|
||||
"""
|
||||
Swap active and staging buffers.
|
||||
|
||||
Called when staging buffer has updated content ready.
|
||||
"""
|
||||
with self._buffer_lock:
|
||||
if self._staging_buffer:
|
||||
# True swap: staging becomes active, old active is discarded
|
||||
self._active_buffer, self._staging_buffer = self._staging_buffer, deque()
|
||||
self.stats['buffer_swaps'] += 1
|
||||
logger.debug("Swapped buffers, active now has %d segments", len(self._active_buffer))
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""
|
||||
Refresh the plugin list and content.
|
||||
|
||||
Called periodically to pick up new plugins or config changes.
|
||||
"""
|
||||
current_time = time.time()
|
||||
if current_time - self._last_refresh < self._refresh_interval:
|
||||
return
|
||||
|
||||
self._last_refresh = current_time
|
||||
old_count = len(self._ordered_plugins)
|
||||
self._refresh_plugin_list()
|
||||
|
||||
if len(self._ordered_plugins) != old_count:
|
||||
logger.info(
|
||||
"Plugin list refreshed: %d -> %d plugins",
|
||||
old_count, len(self._ordered_plugins)
|
||||
)
|
||||
|
||||
def _refresh_plugin_list(self) -> None:
|
||||
"""Refresh the ordered list of plugins from plugin manager."""
|
||||
logger.info("=" * 60)
|
||||
logger.info("REFRESHING PLUGIN LIST FOR VEGAS SCROLL")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Get all enabled plugins
|
||||
available_plugins = []
|
||||
|
||||
if hasattr(self.plugin_manager, 'plugins'):
|
||||
logger.info(
|
||||
"Checking %d loaded plugins for Vegas scroll",
|
||||
len(self.plugin_manager.plugins)
|
||||
)
|
||||
for plugin_id, plugin in self.plugin_manager.plugins.items():
|
||||
has_enabled = hasattr(plugin, 'enabled')
|
||||
is_enabled = getattr(plugin, 'enabled', False)
|
||||
logger.info(
|
||||
"[%s] class=%s, has_enabled=%s, enabled=%s",
|
||||
plugin_id, plugin.__class__.__name__, has_enabled, is_enabled
|
||||
)
|
||||
if has_enabled and is_enabled:
|
||||
# Check vegas content type - skip 'none' unless in STATIC mode
|
||||
content_type = self.plugin_adapter.get_content_type(plugin, plugin_id)
|
||||
|
||||
# Also check display mode - STATIC plugins should be included
|
||||
# even if their content_type is 'none'
|
||||
display_mode = VegasDisplayMode.FIXED_SEGMENT
|
||||
try:
|
||||
display_mode = plugin.get_vegas_display_mode()
|
||||
except Exception:
|
||||
# Plugin error should not abort refresh; use default mode
|
||||
logger.exception(
|
||||
"[%s] (%s) get_vegas_display_mode() failed, using default",
|
||||
plugin_id, plugin.__class__.__name__
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[%s] content_type=%s, display_mode=%s",
|
||||
plugin_id, content_type, display_mode.value
|
||||
)
|
||||
|
||||
if content_type != 'none' or display_mode == VegasDisplayMode.STATIC:
|
||||
available_plugins.append(plugin_id)
|
||||
logger.info("[%s] --> INCLUDED in Vegas scroll", plugin_id)
|
||||
else:
|
||||
logger.info("[%s] --> EXCLUDED from Vegas scroll", plugin_id)
|
||||
else:
|
||||
logger.info("[%s] --> SKIPPED (not enabled)", plugin_id)
|
||||
|
||||
else:
|
||||
logger.warning(
|
||||
"plugin_manager does not have plugins attribute: %s",
|
||||
type(self.plugin_manager).__name__
|
||||
)
|
||||
|
||||
# Apply ordering from config (outside lock for potentially slow operation)
|
||||
ordered_plugins = self.config.get_ordered_plugins(available_plugins)
|
||||
logger.info(
|
||||
"Vegas scroll plugin list: %d available -> %d ordered",
|
||||
len(available_plugins), len(ordered_plugins)
|
||||
)
|
||||
logger.info("Ordered plugins: %s", ordered_plugins)
|
||||
|
||||
# Atomically update shared state under lock to avoid races with prefetchers
|
||||
with self._buffer_lock:
|
||||
self._ordered_plugins = ordered_plugins
|
||||
# Reset indices if needed
|
||||
if self._current_index >= len(self._ordered_plugins):
|
||||
self._current_index = 0
|
||||
if self._prefetch_index >= len(self._ordered_plugins):
|
||||
self._prefetch_index = 0
|
||||
|
||||
logger.info("=" * 60)
|
||||
|
||||
def _prefetch_content(self, count: int = 1) -> None:
|
||||
"""
|
||||
Prefetch content for upcoming plugins.
|
||||
|
||||
Args:
|
||||
count: Number of plugins to prefetch
|
||||
"""
|
||||
with self._buffer_lock:
|
||||
if not self._ordered_plugins:
|
||||
return
|
||||
|
||||
for _ in range(count):
|
||||
if len(self._active_buffer) >= self.config.buffer_ahead + 1:
|
||||
break
|
||||
|
||||
# Ensure index is valid (guard against empty list)
|
||||
num_plugins = len(self._ordered_plugins)
|
||||
if num_plugins == 0:
|
||||
break
|
||||
|
||||
plugin_id = self._ordered_plugins[self._prefetch_index]
|
||||
|
||||
# Release lock for potentially slow content fetch
|
||||
self._buffer_lock.release()
|
||||
try:
|
||||
segment = self._fetch_plugin_content(plugin_id)
|
||||
finally:
|
||||
self._buffer_lock.acquire()
|
||||
|
||||
if segment:
|
||||
self._active_buffer.append(segment)
|
||||
|
||||
# Revalidate num_plugins after reacquiring lock (may have changed)
|
||||
num_plugins = len(self._ordered_plugins)
|
||||
if num_plugins == 0:
|
||||
break
|
||||
|
||||
# Advance prefetch index (thread-safe within lock)
|
||||
self._prefetch_index = (self._prefetch_index + 1) % num_plugins
|
||||
|
||||
def _fetch_plugin_content(self, plugin_id: str) -> Optional[ContentSegment]:
|
||||
"""
|
||||
Fetch content from a specific plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin to fetch from
|
||||
|
||||
Returns:
|
||||
ContentSegment or None if fetch failed
|
||||
"""
|
||||
try:
|
||||
logger.info("=" * 60)
|
||||
logger.info("[%s] FETCHING CONTENT", plugin_id)
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Get plugin instance
|
||||
if not hasattr(self.plugin_manager, 'plugins'):
|
||||
logger.warning("[%s] plugin_manager has no plugins attribute", plugin_id)
|
||||
return None
|
||||
|
||||
plugin = self.plugin_manager.plugins.get(plugin_id)
|
||||
if not plugin:
|
||||
logger.warning("[%s] Plugin not found in plugin_manager.plugins", plugin_id)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"[%s] Plugin found: class=%s, enabled=%s",
|
||||
plugin_id, plugin.__class__.__name__, getattr(plugin, 'enabled', 'N/A')
|
||||
)
|
||||
|
||||
# Get display mode from plugin
|
||||
display_mode = VegasDisplayMode.FIXED_SEGMENT
|
||||
try:
|
||||
display_mode = plugin.get_vegas_display_mode()
|
||||
logger.info("[%s] Display mode: %s", plugin_id, display_mode.value)
|
||||
except (AttributeError, TypeError) as e:
|
||||
logger.info(
|
||||
"[%s] get_vegas_display_mode() not available: %s (using FIXED_SEGMENT)",
|
||||
plugin_id, e
|
||||
)
|
||||
|
||||
# For STATIC mode, we create a placeholder segment
|
||||
# The actual content will be displayed by coordinator during pause
|
||||
if display_mode == VegasDisplayMode.STATIC:
|
||||
# Create minimal placeholder - coordinator handles actual display
|
||||
segment = ContentSegment(
|
||||
plugin_id=plugin_id,
|
||||
images=[], # No images needed for static pause
|
||||
total_width=0,
|
||||
display_mode=display_mode
|
||||
)
|
||||
self.stats['segments_fetched'] += 1
|
||||
logger.info(
|
||||
"[%s] Created STATIC placeholder (pause trigger)",
|
||||
plugin_id
|
||||
)
|
||||
return segment
|
||||
|
||||
# Get content via adapter for SCROLL/FIXED_SEGMENT modes
|
||||
logger.info("[%s] Calling plugin_adapter.get_content()...", plugin_id)
|
||||
images = self.plugin_adapter.get_content(plugin, plugin_id)
|
||||
if not images:
|
||||
logger.warning("[%s] NO CONTENT RETURNED from plugin_adapter", plugin_id)
|
||||
return None
|
||||
|
||||
# Calculate total width
|
||||
total_width = sum(img.width for img in images)
|
||||
|
||||
segment = ContentSegment(
|
||||
plugin_id=plugin_id,
|
||||
images=images,
|
||||
total_width=total_width,
|
||||
display_mode=display_mode
|
||||
)
|
||||
|
||||
self.stats['segments_fetched'] += 1
|
||||
logger.info(
|
||||
"[%s] SEGMENT CREATED: %d images, %dpx total, mode=%s",
|
||||
plugin_id, len(images), total_width, display_mode.value
|
||||
)
|
||||
logger.info("=" * 60)
|
||||
|
||||
return segment
|
||||
|
||||
except Exception:
|
||||
logger.exception("[%s] ERROR fetching content", plugin_id)
|
||||
self.stats['fetch_errors'] += 1
|
||||
return None
|
||||
|
||||
def _refresh_plugin_content(self, plugin_id: str) -> None:
|
||||
"""
|
||||
Refresh content for a specific plugin into staging buffer.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin to refresh
|
||||
"""
|
||||
# Invalidate cached content
|
||||
self.plugin_adapter.invalidate_cache(plugin_id)
|
||||
|
||||
# Fetch fresh content
|
||||
segment = self._fetch_plugin_content(plugin_id)
|
||||
|
||||
if segment:
|
||||
with self._buffer_lock:
|
||||
self._staging_buffer.append(segment)
|
||||
logger.debug("Refreshed content for %s in staging buffer", plugin_id)
|
||||
|
||||
def _ensure_buffer_filled(self) -> None:
|
||||
"""Ensure buffer has enough content prefetched."""
|
||||
if len(self._active_buffer) < self.config.buffer_ahead:
|
||||
needed = self.config.buffer_ahead - len(self._active_buffer)
|
||||
self._prefetch_content(count=needed)
|
||||
|
||||
def get_all_content_for_composition(self) -> List[Image.Image]:
|
||||
"""
|
||||
Get all buffered content as a flat list of images.
|
||||
|
||||
Used when composing the full scroll image.
|
||||
Skips STATIC segments as they don't have images to compose.
|
||||
|
||||
Returns:
|
||||
List of all images in buffer order
|
||||
"""
|
||||
all_images = []
|
||||
with self._buffer_lock:
|
||||
for segment in self._active_buffer:
|
||||
# Skip STATIC segments - they trigger pauses, not scroll content
|
||||
if segment.display_mode != VegasDisplayMode.STATIC:
|
||||
all_images.extend(segment.images)
|
||||
return all_images
|
||||
|
||||
def advance_cycle(self) -> None:
|
||||
"""
|
||||
Advance to next cycle by clearing the active buffer.
|
||||
|
||||
Called when a scroll cycle completes to allow fresh content
|
||||
to be fetched for the next cycle. Does not reset indices,
|
||||
so prefetching continues from the current position in the
|
||||
plugin order.
|
||||
"""
|
||||
with self._buffer_lock:
|
||||
consumed_count = len(self._active_buffer)
|
||||
self._active_buffer.clear()
|
||||
logger.debug("Advanced cycle, cleared %d segments", consumed_count)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the stream manager state."""
|
||||
with self._buffer_lock:
|
||||
self._active_buffer.clear()
|
||||
self._staging_buffer.clear()
|
||||
self._current_index = 0
|
||||
self._prefetch_index = 0
|
||||
self._pending_updates.clear()
|
||||
|
||||
self.plugin_adapter.invalidate_cache()
|
||||
logger.info("StreamManager reset")
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up resources."""
|
||||
self.reset()
|
||||
self.plugin_adapter.cleanup()
|
||||
logger.debug("StreamManager cleanup complete")
|
||||
Reference in New Issue
Block a user