From 132ded241d3fddcd9416bfaf6c0c1abba4418a7b Mon Sep 17 00:00:00 2001 From: Chuck Date: Wed, 28 Jan 2026 08:08:04 -0500 Subject: [PATCH 01/28] 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 --- config/config.template.json | 11 +- src/display_controller.py | 70 +++ src/plugin_system/base_plugin.py | 48 +++ src/vegas_mode/__init__.py | 21 + src/vegas_mode/config.py | 194 +++++++++ src/vegas_mode/coordinator.py | 405 +++++++++++++++++ src/vegas_mode/plugin_adapter.py | 322 ++++++++++++++ src/vegas_mode/render_pipeline.py | 355 +++++++++++++++ src/vegas_mode/stream_manager.py | 406 ++++++++++++++++++ web_interface/blueprints/api_v3.py | 44 ++ .../templates/v3/partials/display.html | 277 +++++++++++- 11 files changed, 2151 insertions(+), 2 deletions(-) create mode 100644 src/vegas_mode/__init__.py create mode 100644 src/vegas_mode/config.py create mode 100644 src/vegas_mode/coordinator.py create mode 100644 src/vegas_mode/plugin_adapter.py create mode 100644 src/vegas_mode/render_pipeline.py create mode 100644 src/vegas_mode/stream_manager.py diff --git a/config/config.template.json b/config/config.template.json index 6d8ab3c8b..baf5757de 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -72,7 +72,16 @@ "display_durations": { "calendar": 30 }, - "use_short_date_format": true + "use_short_date_format": true, + "vegas_scroll": { + "enabled": false, + "scroll_speed": 50, + "separator_width": 32, + "plugin_order": [], + "excluded_plugins": [], + "target_fps": 125, + "buffer_ahead": 2 + } }, "plugin_system": { "plugins_directory": "plugin-repos", diff --git a/src/display_controller.py b/src/display_controller.py index 6b44ff462..5cf337d85 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -18,6 +18,10 @@ # Get logger with consistent configuration logger = get_logger(__name__) + +# Vegas mode import (lazy loaded to avoid circular imports) +_vegas_mode_imported = False +VegasModeCoordinator = None DEFAULT_DYNAMIC_DURATION_CAP = 180.0 # WiFi status message file path (same as used in wifi_manager.py) @@ -343,8 +347,57 @@ def config_change_callback(old_config: Dict[str, Any], new_config: Dict[str, Any self._update_modules() logger.info("Initial plugin update completed in %.3f seconds", time.time() - update_start) + # Initialize Vegas mode coordinator + self.vegas_coordinator = None + self._initialize_vegas_mode() + logger.info("DisplayController initialization completed in %.3f seconds", time.time() - start_time) + def _initialize_vegas_mode(self): + """Initialize Vegas mode coordinator if enabled.""" + global _vegas_mode_imported, VegasModeCoordinator + + vegas_config = self.config.get('display', {}).get('vegas_scroll', {}) + if not vegas_config.get('enabled', False): + logger.debug("Vegas mode disabled in config") + return + + try: + # Lazy import to avoid circular imports + if not _vegas_mode_imported: + try: + from src.vegas_mode import VegasModeCoordinator as VMC + VegasModeCoordinator = VMC + _vegas_mode_imported = True + except ImportError as ie: + logger.error("Failed to import Vegas mode module: %s", ie) + return + + self.vegas_coordinator = VegasModeCoordinator( + config=self.config, + display_manager=self.display_manager, + plugin_manager=self.plugin_manager + ) + + # Set up live priority checker + self.vegas_coordinator.set_live_priority_checker(self._check_live_priority) + + logger.info("Vegas mode coordinator initialized") + + except Exception as e: + logger.error("Failed to initialize Vegas mode: %s", e, exc_info=True) + self.vegas_coordinator = None + + def _is_vegas_mode_active(self) -> bool: + """Check if Vegas mode should be running.""" + if not self.vegas_coordinator: + return False + if not self.vegas_coordinator.is_enabled: + return False + if self.on_demand_active: + return False # On-demand takes priority + return True + def _check_schedule(self): """Check if display should be active based on schedule.""" schedule_config = self.config.get('schedule', {}) @@ -1152,6 +1205,23 @@ def run(self): except ValueError: pass + # Vegas scroll mode - continuous ticker across all plugins + # Priority: on-demand > wifi-status > live-priority > vegas > normal rotation + if self._is_vegas_mode_active() and not wifi_status_data: + live_mode = self._check_live_priority() + if not live_mode: + try: + # Run Vegas mode iteration + if self.vegas_coordinator.run_iteration(): + # Vegas completed an iteration, continue to next loop + continue + else: + # Vegas was interrupted (live priority), fall through to normal handling + logger.debug("Vegas mode interrupted, falling back to normal rotation") + except Exception as e: + logger.error("Vegas mode error: %s", e) + # Fall through to normal rotation on error + if self.on_demand_active: # Guard against empty on_demand_modes if not self.on_demand_modes: diff --git a/src/plugin_system/base_plugin.py b/src/plugin_system/base_plugin.py index 5c804e1bb..ee23633da 100644 --- a/src/plugin_system/base_plugin.py +++ b/src/plugin_system/base_plugin.py @@ -285,6 +285,54 @@ def get_live_modes(self): return manifest.get("display_modes", [self.plugin_id]) return [self.plugin_id] + # ------------------------------------------------------------------------- + # Vegas scroll mode support + # ------------------------------------------------------------------------- + def get_vegas_content(self) -> Optional[Any]: + """ + Get content for Vegas-style continuous scroll mode. + + Override this method to provide optimized content for continuous scrolling. + Plugins can return: + - A single PIL Image: Displayed as a static block in the scroll + - A list of PIL Images: Each image becomes a separate item in the scroll + - None: Vegas mode will fall back to capturing display() output + + Multi-item plugins (sports scores, odds) should return individual game/item + images so they scroll smoothly with other plugins. + + Returns: + PIL Image, list of PIL Images, or None + + Example (sports plugin): + def get_vegas_content(self): + # Return individual game cards for smooth scrolling + return [self._render_game(game) for game in self.games] + + Example (static plugin): + def get_vegas_content(self): + # Return current display as single block + return self._render_current_view() + """ + return None + + def get_vegas_content_type(self) -> str: + """ + Indicate the type of content this plugin provides for Vegas scroll. + + Override this to specify how Vegas mode should treat this plugin's content. + + Returns: + 'multi' - Plugin has multiple scrollable items (sports, odds, news) + 'static' - Plugin is a static block (clock, weather, music) + 'none' - Plugin should not appear in Vegas scroll mode + + Example: + def get_vegas_content_type(self): + return 'multi' # We have multiple games to scroll + """ + return 'static' + def validate_config(self) -> bool: """ Validate plugin configuration against schema. diff --git a/src/vegas_mode/__init__.py b/src/vegas_mode/__init__.py new file mode 100644 index 000000000..6cea87a5e --- /dev/null +++ b/src/vegas_mode/__init__.py @@ -0,0 +1,21 @@ +""" +Vegas Mode - Continuous Scrolling Ticker + +This package implements a Vegas-style continuous scroll mode where all enabled +plugins' content is composed into a single horizontally scrolling display. + +Components: +- VegasModeCoordinator: Main orchestrator for Vegas mode +- StreamManager: Manages plugin content streaming with 1-2 ahead buffering +- RenderPipeline: Handles 125 FPS rendering with double-buffering +- PluginAdapter: Converts plugin content to scrollable images +- VegasModeConfig: Configuration management +""" + +from src.vegas_mode.config import VegasModeConfig +from src.vegas_mode.coordinator import VegasModeCoordinator + +__all__ = [ + 'VegasModeConfig', + 'VegasModeCoordinator', +] diff --git a/src/vegas_mode/config.py b/src/vegas_mode/config.py new file mode 100644 index 000000000..d8d6ad76e --- /dev/null +++ b/src/vegas_mode/config.py @@ -0,0 +1,194 @@ +""" +Vegas Mode Configuration + +Handles configuration for Vegas-style continuous scroll mode including +plugin ordering, exclusions, scroll speed, and display settings. +""" + +import logging +from typing import Dict, Any, List, Set, Optional +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + + +@dataclass +class VegasModeConfig: + """Configuration for Vegas scroll mode.""" + + # Core settings + enabled: bool = False + scroll_speed: float = 50.0 # Pixels per second + separator_width: int = 32 # Gap between plugins (pixels) + + # Plugin management + plugin_order: List[str] = field(default_factory=list) + excluded_plugins: Set[str] = field(default_factory=set) + + # Performance settings + target_fps: int = 125 # Target frame rate + buffer_ahead: int = 2 # Number of plugins to buffer ahead + + # Scroll behavior + frame_based_scrolling: bool = True + scroll_delay: float = 0.02 # 50 FPS effective scroll updates + + # Dynamic duration + dynamic_duration_enabled: bool = True + min_cycle_duration: int = 60 # Minimum seconds per full cycle + max_cycle_duration: int = 600 # Maximum seconds per full cycle + + @classmethod + def from_config(cls, config: Dict[str, Any]) -> 'VegasModeConfig': + """ + Create VegasModeConfig from main configuration dictionary. + + Args: + config: Main config dict (expects config['display']['vegas_scroll']) + + Returns: + VegasModeConfig instance + """ + vegas_config = config.get('display', {}).get('vegas_scroll', {}) + + return cls( + enabled=vegas_config.get('enabled', False), + scroll_speed=float(vegas_config.get('scroll_speed', 50.0)), + separator_width=int(vegas_config.get('separator_width', 32)), + plugin_order=list(vegas_config.get('plugin_order', [])), + excluded_plugins=set(vegas_config.get('excluded_plugins', [])), + target_fps=int(vegas_config.get('target_fps', 125)), + buffer_ahead=int(vegas_config.get('buffer_ahead', 2)), + frame_based_scrolling=vegas_config.get('frame_based_scrolling', True), + scroll_delay=float(vegas_config.get('scroll_delay', 0.02)), + dynamic_duration_enabled=vegas_config.get('dynamic_duration_enabled', True), + min_cycle_duration=int(vegas_config.get('min_cycle_duration', 60)), + max_cycle_duration=int(vegas_config.get('max_cycle_duration', 600)), + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert config to dictionary for serialization.""" + return { + 'enabled': self.enabled, + 'scroll_speed': self.scroll_speed, + 'separator_width': self.separator_width, + 'plugin_order': self.plugin_order, + 'excluded_plugins': list(self.excluded_plugins), + 'target_fps': self.target_fps, + 'buffer_ahead': self.buffer_ahead, + 'frame_based_scrolling': self.frame_based_scrolling, + 'scroll_delay': self.scroll_delay, + 'dynamic_duration_enabled': self.dynamic_duration_enabled, + 'min_cycle_duration': self.min_cycle_duration, + 'max_cycle_duration': self.max_cycle_duration, + } + + def get_frame_interval(self) -> float: + """Get the frame interval in seconds for target FPS.""" + return 1.0 / max(1, self.target_fps) + + def is_plugin_included(self, plugin_id: str) -> bool: + """ + Check if a plugin should be included in Vegas scroll. + + Args: + plugin_id: Plugin identifier to check + + Returns: + True if plugin should be included + """ + # If explicit order is set, only include those plugins + if self.plugin_order: + return plugin_id in self.plugin_order and plugin_id not in self.excluded_plugins + # Otherwise include all non-excluded plugins + return plugin_id not in self.excluded_plugins + + def get_ordered_plugins(self, available_plugins: List[str]) -> List[str]: + """ + Get plugins in configured order, filtering excluded ones. + + Args: + available_plugins: List of all available plugin IDs + + Returns: + Ordered list of plugin IDs to include in Vegas scroll + """ + if self.plugin_order: + # Use explicit order, filter to only available and non-excluded + ordered = [ + p for p in self.plugin_order + if p in available_plugins and p not in self.excluded_plugins + ] + # Add any available plugins not in the order list (at the end) + for p in available_plugins: + if p not in ordered and p not in self.excluded_plugins: + ordered.append(p) + return ordered + else: + # Use natural order, filter excluded + return [p for p in available_plugins if p not in self.excluded_plugins] + + def validate(self) -> List[str]: + """ + Validate configuration values. + + Returns: + List of validation error messages (empty if valid) + """ + errors = [] + + if self.scroll_speed < 1.0: + errors.append(f"scroll_speed must be >= 1.0, got {self.scroll_speed}") + if self.scroll_speed > 500.0: + errors.append(f"scroll_speed must be <= 500.0, got {self.scroll_speed}") + + if self.separator_width < 0: + errors.append(f"separator_width must be >= 0, got {self.separator_width}") + if self.separator_width > 256: + errors.append(f"separator_width must be <= 256, got {self.separator_width}") + + if self.target_fps < 30: + errors.append(f"target_fps must be >= 30, got {self.target_fps}") + if self.target_fps > 200: + errors.append(f"target_fps must be <= 200, got {self.target_fps}") + + if self.buffer_ahead < 1: + errors.append(f"buffer_ahead must be >= 1, got {self.buffer_ahead}") + if self.buffer_ahead > 5: + errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}") + + return errors + + def update(self, new_config: Dict[str, Any]) -> None: + """ + Update configuration from new values. + + Args: + new_config: New configuration values to apply + """ + vegas_config = new_config.get('display', {}).get('vegas_scroll', {}) + + if 'enabled' in vegas_config: + self.enabled = vegas_config['enabled'] + if 'scroll_speed' in vegas_config: + self.scroll_speed = float(vegas_config['scroll_speed']) + if 'separator_width' in vegas_config: + self.separator_width = int(vegas_config['separator_width']) + if 'plugin_order' in vegas_config: + self.plugin_order = list(vegas_config['plugin_order']) + if 'excluded_plugins' in vegas_config: + self.excluded_plugins = set(vegas_config['excluded_plugins']) + if 'target_fps' in vegas_config: + self.target_fps = int(vegas_config['target_fps']) + if 'buffer_ahead' in vegas_config: + self.buffer_ahead = int(vegas_config['buffer_ahead']) + if 'frame_based_scrolling' in vegas_config: + self.frame_based_scrolling = vegas_config['frame_based_scrolling'] + if 'scroll_delay' in vegas_config: + self.scroll_delay = float(vegas_config['scroll_delay']) + + # Log config update + logger.info( + "Vegas mode config updated: enabled=%s, speed=%.1f, fps=%d, buffer=%d", + self.enabled, self.scroll_speed, self.target_fps, self.buffer_ahead + ) diff --git a/src/vegas_mode/coordinator.py b/src/vegas_mode/coordinator.py new file mode 100644 index 000000000..2151bec4c --- /dev/null +++ b/src/vegas_mode/coordinator.py @@ -0,0 +1,405 @@ +""" +Vegas Mode Coordinator + +Main orchestrator for Vegas-style continuous scroll mode. Coordinates between +StreamManager, RenderPipeline, and the display system to provide smooth +continuous scrolling of all enabled plugin content. +""" + +import logging +import time +import threading +from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING + +from src.vegas_mode.config import VegasModeConfig +from src.vegas_mode.plugin_adapter import PluginAdapter +from src.vegas_mode.stream_manager import StreamManager +from src.vegas_mode.render_pipeline import RenderPipeline + +if TYPE_CHECKING: + from src.plugin_system.plugin_manager import PluginManager + from src.display_manager import DisplayManager + +logger = logging.getLogger(__name__) + + +class VegasModeCoordinator: + """ + Orchestrates Vegas scroll mode operation. + + Responsibilities: + - Initialize and coordinate all Vegas mode components + - Manage the high-FPS render loop + - Handle live priority interruptions + - Process config updates + - Provide status and control interface + """ + + def __init__( + self, + config: Dict[str, Any], + display_manager: 'DisplayManager', + plugin_manager: 'PluginManager' + ): + """ + Initialize the Vegas mode coordinator. + + Args: + config: Main configuration dictionary + display_manager: DisplayManager instance + plugin_manager: PluginManager instance + """ + # Parse configuration + self.vegas_config = VegasModeConfig.from_config(config) + + # Store references + self.display_manager = display_manager + self.plugin_manager = plugin_manager + + # Initialize components + self.plugin_adapter = PluginAdapter(display_manager) + self.stream_manager = StreamManager( + self.vegas_config, + plugin_manager, + self.plugin_adapter + ) + self.render_pipeline = RenderPipeline( + self.vegas_config, + display_manager, + self.stream_manager + ) + + # State management + self._is_active = False + self._is_paused = False + self._should_stop = False + self._state_lock = threading.Lock() + + # Live priority tracking + self._live_priority_active = False + self._live_priority_check: Optional[Callable[[], Optional[str]]] = None + + # Config update tracking + self._config_version = 0 + self._pending_config_update = False + + # Statistics + self.stats = { + 'total_runtime_seconds': 0.0, + 'cycles_completed': 0, + 'interruptions': 0, + 'config_updates': 0, + } + self._start_time: Optional[float] = None + + logger.info( + "VegasModeCoordinator initialized: enabled=%s, fps=%d, buffer_ahead=%d", + self.vegas_config.enabled, + self.vegas_config.target_fps, + self.vegas_config.buffer_ahead + ) + + @property + def is_enabled(self) -> bool: + """Check if Vegas mode is enabled in configuration.""" + return self.vegas_config.enabled + + @property + def is_active(self) -> bool: + """Check if Vegas mode is currently running.""" + return self._is_active + + def set_live_priority_checker(self, checker: Callable[[], Optional[str]]) -> None: + """ + Set the callback for checking live priority content. + + Args: + checker: Callable that returns live priority mode name or None + """ + self._live_priority_check = checker + + def start(self) -> bool: + """ + Start Vegas mode operation. + + Returns: + True if started successfully + """ + if not self.vegas_config.enabled: + logger.warning("Cannot start Vegas mode - not enabled in config") + return False + + with self._state_lock: + if self._is_active: + logger.warning("Vegas mode already active") + return True + + # Validate configuration + errors = self.vegas_config.validate() + if errors: + logger.error("Vegas config validation failed: %s", errors) + return False + + # Initialize stream manager + if not self.stream_manager.initialize(): + logger.error("Failed to initialize stream manager") + return False + + # Compose initial content + if not self.render_pipeline.compose_scroll_content(): + logger.error("Failed to compose initial scroll content") + return False + + self._is_active = True + self._should_stop = False + self._start_time = time.time() + + logger.info("Vegas mode started") + return True + + def stop(self) -> None: + """Stop Vegas mode operation.""" + with self._state_lock: + if not self._is_active: + return + + self._should_stop = True + self._is_active = False + + if self._start_time: + self.stats['total_runtime_seconds'] += time.time() - self._start_time + self._start_time = None + + # Cleanup components + self.render_pipeline.reset() + self.stream_manager.reset() + self.display_manager.set_scrolling_state(False) + + logger.info("Vegas mode stopped") + + def pause(self) -> None: + """Pause Vegas mode (for live priority interruption).""" + with self._state_lock: + if not self._is_active: + return + self._is_paused = True + self.stats['interruptions'] += 1 + + self.display_manager.set_scrolling_state(False) + logger.info("Vegas mode paused") + + def resume(self) -> None: + """Resume Vegas mode after pause.""" + with self._state_lock: + if not self._is_active: + return + self._is_paused = False + + logger.info("Vegas mode resumed") + + def run_frame(self) -> bool: + """ + Run a single frame of Vegas mode. + + Should be called at target FPS (e.g., 125 FPS = every 8ms). + + Returns: + True if frame was rendered, False if Vegas mode is not active + """ + # Check if we should be running + with self._state_lock: + if not self._is_active or self._is_paused or self._should_stop: + return False + # Check for config updates (synchronized access) + has_pending_update = self._pending_config_update + + # Check for live priority + if self._check_live_priority(): + return False + + # Apply pending config update outside lock + if has_pending_update: + self._apply_pending_config() + + # Check if we need to start a new cycle + if self.render_pipeline.is_cycle_complete(): + if not self.render_pipeline.start_new_cycle(): + logger.warning("Failed to start new Vegas cycle") + return False + self.stats['cycles_completed'] += 1 + + # Check for hot-swap opportunities + if self.render_pipeline.should_recompose(): + self.render_pipeline.hot_swap_content() + + # Render frame + return self.render_pipeline.render_frame() + + def run_iteration(self) -> bool: + """ + Run a complete Vegas mode iteration (display duration). + + This is called by DisplayController to run Vegas mode for one + "display duration" period before checking for mode changes. + + Returns: + True if iteration completed normally, False if interrupted + """ + if not self.is_active: + if not self.start(): + return False + + frame_interval = self.vegas_config.get_frame_interval() + duration = self.render_pipeline.get_dynamic_duration() + start_time = time.time() + + logger.info("Starting Vegas iteration for %.1fs", duration) + + while True: + # Run frame + if not self.run_frame(): + # Check why we stopped + with self._state_lock: + if self._should_stop: + return False + if self._is_paused: + # Paused for live priority - let caller handle + return False + + # Sleep for frame interval + time.sleep(frame_interval) + + # Check elapsed time + elapsed = time.time() - start_time + if elapsed >= duration: + break + + # Check for cycle completion + if self.render_pipeline.is_cycle_complete(): + break + + logger.info("Vegas iteration completed after %.1fs", time.time() - start_time) + return True + + def _check_live_priority(self) -> bool: + """ + Check if live priority content should interrupt Vegas mode. + + Returns: + True if Vegas mode should be paused for live priority + """ + if not self._live_priority_check: + return False + + try: + live_mode = self._live_priority_check() + if live_mode: + if not self._live_priority_active: + self._live_priority_active = True + self.pause() + logger.info("Live priority detected: %s - pausing Vegas", live_mode) + return True + else: + if self._live_priority_active: + self._live_priority_active = False + self.resume() + logger.info("Live priority ended - resuming Vegas") + return False + except Exception as e: + logger.error("Error checking live priority: %s", e) + return False + + def update_config(self, new_config: Dict[str, Any]) -> None: + """ + Update Vegas mode configuration. + + Config changes are applied at next safe point to avoid disruption. + + Args: + new_config: New configuration dictionary + """ + with self._state_lock: + self._pending_config_update = True + self._pending_config = new_config + self._config_version += 1 + self.stats['config_updates'] += 1 + + logger.debug("Config update queued (version %d)", self._config_version) + + def _apply_pending_config(self) -> None: + """Apply pending configuration update.""" + with self._state_lock: + if not hasattr(self, '_pending_config'): + self._pending_config_update = False + return + pending_config = self._pending_config + + try: + new_vegas_config = VegasModeConfig.from_config(pending_config) + + # Check if enabled state changed + was_enabled = self.vegas_config.enabled + self.vegas_config = new_vegas_config + + # Update components + self.render_pipeline.update_config(new_vegas_config) + + # Handle enable/disable + if was_enabled and not new_vegas_config.enabled: + self.stop() + elif not was_enabled and new_vegas_config.enabled: + self.start() + + logger.info("Config update applied (version %d)", self._config_version) + + except Exception as e: + logger.error("Error applying config update: %s", e) + + finally: + with self._state_lock: + self._pending_config_update = False + if hasattr(self, '_pending_config'): + delattr(self, '_pending_config') + + def mark_plugin_updated(self, plugin_id: str) -> None: + """ + Notify that a plugin's data has been updated. + + Args: + plugin_id: ID of plugin that was updated + """ + if self._is_active: + self.stream_manager.mark_plugin_updated(plugin_id) + self.plugin_adapter.invalidate_cache(plugin_id) + + def get_status(self) -> Dict[str, Any]: + """Get comprehensive Vegas mode status.""" + status = { + 'enabled': self.vegas_config.enabled, + 'active': self._is_active, + 'paused': self._is_paused, + 'live_priority_active': self._live_priority_active, + 'config': self.vegas_config.to_dict(), + 'stats': self.stats.copy(), + } + + if self._is_active: + status['render_info'] = self.render_pipeline.get_current_scroll_info() + status['stream_status'] = self.stream_manager.get_buffer_status() + + return status + + def get_ordered_plugins(self) -> List[str]: + """Get the current ordered list of plugins in Vegas scroll.""" + if hasattr(self.plugin_manager, 'loaded_plugins'): + available = list(self.plugin_manager.loaded_plugins.keys()) + return self.vegas_config.get_ordered_plugins(available) + return [] + + def cleanup(self) -> None: + """Clean up all resources.""" + self.stop() + self.render_pipeline.cleanup() + self.stream_manager.cleanup() + self.plugin_adapter.cleanup() + logger.info("VegasModeCoordinator cleanup complete") diff --git a/src/vegas_mode/plugin_adapter.py b/src/vegas_mode/plugin_adapter.py new file mode 100644 index 000000000..ddf46cd37 --- /dev/null +++ b/src/vegas_mode/plugin_adapter.py @@ -0,0 +1,322 @@ +""" +Plugin Adapter for Vegas Mode + +Converts plugin content to scrollable images. Supports both plugins that +implement get_vegas_content() and fallback capture of display() output. +""" + +import logging +import time +import copy +from typing import Optional, List, Any, TYPE_CHECKING +from PIL import Image + +if TYPE_CHECKING: + from src.plugin_system.base_plugin import BasePlugin + +logger = logging.getLogger(__name__) + + +class PluginAdapter: + """ + Adapter for extracting scrollable content from plugins. + + Supports two modes: + 1. Native: Plugin implements get_vegas_content() returning PIL Image(s) + 2. Fallback: Capture display_manager.image after calling plugin.display() + """ + + def __init__(self, display_manager: Any): + """ + Initialize the plugin adapter. + + Args: + display_manager: DisplayManager instance for fallback capture + """ + self.display_manager = display_manager + self.display_width = display_manager.width + self.display_height = display_manager.height + + # Cache for recently fetched content (prevents redundant fetch) + self._content_cache: dict = {} + self._cache_ttl = 5.0 # Cache for 5 seconds + + logger.info( + "PluginAdapter initialized: display=%dx%d", + self.display_width, self.display_height + ) + + def get_content(self, plugin: 'BasePlugin', plugin_id: str) -> Optional[List[Image.Image]]: + """ + Get scrollable content from a plugin. + + Tries get_vegas_content() first, falls back to display capture. + + Args: + plugin: Plugin instance to get content from + plugin_id: Plugin identifier for logging + + Returns: + List of PIL Images representing plugin content, or None if no content + """ + try: + # Check cache first + cached = self._get_cached(plugin_id) + if cached is not None: + logger.debug("Using cached content for %s", plugin_id) + return cached + + # Try native Vegas content method first + if hasattr(plugin, 'get_vegas_content'): + content = self._get_native_content(plugin, plugin_id) + if content: + self._cache_content(plugin_id, content) + return content + + # Fall back to display capture + content = self._capture_display_content(plugin, plugin_id) + if content: + self._cache_content(plugin_id, content) + return content + + logger.warning("No content available from plugin %s", plugin_id) + return None + + except Exception as e: + logger.error("Error getting content from plugin %s: %s", plugin_id, e) + return None + + def _get_native_content( + self, plugin: 'BasePlugin', plugin_id: str + ) -> Optional[List[Image.Image]]: + """ + Get content via plugin's native get_vegas_content() method. + + Args: + plugin: Plugin instance + plugin_id: Plugin identifier + + Returns: + List of images or None + """ + try: + result = plugin.get_vegas_content() + + if result is None: + logger.debug("Plugin %s get_vegas_content() returned None", plugin_id) + return None + + # Normalize to list + if isinstance(result, Image.Image): + images = [result] + elif isinstance(result, (list, tuple)): + images = list(result) + else: + logger.warning( + "Plugin %s get_vegas_content() returned unexpected type: %s", + plugin_id, type(result) + ) + return None + + # Validate images + valid_images = [] + for img in images: + if not isinstance(img, Image.Image): + logger.warning( + "Plugin %s returned non-Image in list: %s", + plugin_id, type(img) + ) + continue + + # Ensure correct height + if img.height != self.display_height: + logger.debug( + "Resizing content from %s: %dx%d -> %dx%d", + plugin_id, img.width, img.height, + img.width, self.display_height + ) + img = img.resize( + (img.width, self.display_height), + Image.Resampling.LANCZOS + ) + + # Convert to RGB if needed + if img.mode != 'RGB': + img = img.convert('RGB') + + valid_images.append(img) + + if valid_images: + logger.debug( + "Got %d native images from %s (total width: %dpx)", + len(valid_images), plugin_id, + sum(img.width for img in valid_images) + ) + return valid_images + + return None + + except Exception as e: + logger.error( + "Error calling get_vegas_content() on %s: %s", + plugin_id, e + ) + return None + + def _capture_display_content( + self, plugin: 'BasePlugin', plugin_id: str + ) -> Optional[List[Image.Image]]: + """ + Capture content by calling plugin.display() and grabbing the frame. + + Args: + plugin: Plugin instance + plugin_id: Plugin identifier + + Returns: + List with single captured image, or None + """ + original_image = None + try: + # Save current display state + original_image = self.display_manager.image.copy() + + # Clear and call plugin display + self.display_manager.clear() + plugin.display(force_clear=True) + + # Capture the result + captured = self.display_manager.image.copy() + + # Check if captured image has content (not all black) + if self._is_blank_image(captured): + logger.debug("Plugin %s produced blank image", plugin_id) + return None + + # Convert to RGB if needed + if captured.mode != 'RGB': + captured = captured.convert('RGB') + + logger.debug( + "Captured display content from %s: %dx%d", + plugin_id, captured.width, captured.height + ) + + return [captured] + + except Exception as e: + logger.error( + "Error capturing display from %s: %s", + plugin_id, e + ) + return None + + finally: + # Always restore original image to prevent display corruption + if original_image is not None: + self.display_manager.image = original_image + + def _is_blank_image(self, img: Image.Image) -> bool: + """ + Check if an image is essentially blank (all black or nearly so). + + Args: + img: Image to check + + Returns: + True if image is blank + """ + # Convert to RGB for consistent checking + if img.mode != 'RGB': + img = img.convert('RGB') + + # Sample some pixels rather than checking all + width, height = img.size + sample_points = [ + (width // 4, height // 4), + (width // 2, height // 2), + (3 * width // 4, 3 * height // 4), + (width // 4, 3 * height // 4), + (3 * width // 4, height // 4), + ] + + for x, y in sample_points: + pixel = img.getpixel((x, y)) + if isinstance(pixel, tuple): + if sum(pixel[:3]) > 30: # Not very dark + return False + elif pixel > 10: + return False + + return True + + def _get_cached(self, plugin_id: str) -> Optional[List[Image.Image]]: + """Get cached content if still valid.""" + if plugin_id not in self._content_cache: + return None + + cached_time, content = self._content_cache[plugin_id] + if time.time() - cached_time > self._cache_ttl: + del self._content_cache[plugin_id] + return None + + return content + + def _cache_content(self, plugin_id: str, content: List[Image.Image]) -> None: + """Cache content for a plugin.""" + # Periodic cleanup of expired entries to prevent memory leak + self._cleanup_expired_cache() + + # Make copies to prevent mutation + cached_content = [img.copy() for img in content] + self._content_cache[plugin_id] = (time.time(), cached_content) + + def _cleanup_expired_cache(self) -> None: + """Remove expired entries from cache to prevent memory leaks.""" + current_time = time.time() + expired_keys = [ + key for key, (cached_time, _) in self._content_cache.items() + if current_time - cached_time > self._cache_ttl + ] + for key in expired_keys: + del self._content_cache[key] + + def invalidate_cache(self, plugin_id: Optional[str] = None) -> None: + """ + Invalidate cached content. + + Args: + plugin_id: Specific plugin to invalidate, or None for all + """ + if plugin_id: + self._content_cache.pop(plugin_id, None) + else: + self._content_cache.clear() + + def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str: + """ + Get the type of content a plugin provides. + + Args: + plugin: Plugin instance + plugin_id: Plugin identifier + + Returns: + 'multi' for multiple items, 'static' for single frame, 'none' for excluded + """ + if hasattr(plugin, 'get_vegas_content_type'): + try: + return plugin.get_vegas_content_type() + except Exception as e: + logger.warning( + "Error calling get_vegas_content_type() on %s: %s", + plugin_id, e + ) + + # Default to static for plugins without explicit type + return 'static' + + def cleanup(self) -> None: + """Clean up resources.""" + self._content_cache.clear() + logger.debug("PluginAdapter cleanup complete") diff --git a/src/vegas_mode/render_pipeline.py b/src/vegas_mode/render_pipeline.py new file mode 100644 index 000000000..47821158e --- /dev/null +++ b/src/vegas_mode/render_pipeline.py @@ -0,0 +1,355 @@ +""" +Render Pipeline for Vegas Mode + +Handles high-FPS (125 FPS) rendering with double-buffering for smooth scrolling. +Uses the existing ScrollHelper for numpy-optimized scroll operations. +""" + +import logging +import time +import threading +from collections import deque +from typing import Optional, List, Any, Dict, Deque, TYPE_CHECKING +from PIL import Image +import numpy as np + +from src.common.scroll_helper import ScrollHelper +from src.vegas_mode.config import VegasModeConfig +from src.vegas_mode.stream_manager import StreamManager, ContentSegment + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + + +class RenderPipeline: + """ + High-performance render pipeline for Vegas scroll mode. + + Key responsibilities: + - Compose content segments into scrollable image + - Manage scroll position and velocity + - Handle 125 FPS rendering loop + - Double-buffer for hot-swap during updates + - Track scroll cycle completion + """ + + def __init__( + self, + config: VegasModeConfig, + display_manager: Any, + stream_manager: StreamManager + ): + """ + Initialize the render pipeline. + + Args: + config: Vegas mode configuration + display_manager: DisplayManager for rendering + stream_manager: StreamManager for content + """ + self.config = config + self.display_manager = display_manager + self.stream_manager = stream_manager + + # Display dimensions + self.display_width = display_manager.width + self.display_height = display_manager.height + + # ScrollHelper for optimized scrolling + self.scroll_helper = ScrollHelper( + self.display_width, + self.display_height, + logger + ) + + # Configure scroll helper + self._configure_scroll_helper() + + # Double-buffer for composed images + self._active_scroll_image: Optional[Image.Image] = None + self._staging_scroll_image: Optional[Image.Image] = None + self._buffer_lock = threading.Lock() + + # Render state + self._is_rendering = False + self._cycle_complete = False + self._segments_in_scroll: List[str] = [] # Plugin IDs in current scroll + + # Timing + self._last_frame_time = 0.0 + self._frame_interval = config.get_frame_interval() + self._cycle_start_time = 0.0 + + # Statistics + self.stats = { + 'frames_rendered': 0, + 'scroll_cycles': 0, + 'composition_count': 0, + 'hot_swaps': 0, + 'avg_frame_time_ms': 0.0, + } + self._frame_times: Deque[float] = deque(maxlen=100) # Efficient fixed-size buffer + + logger.info( + "RenderPipeline initialized: %dx%d @ %d FPS", + self.display_width, self.display_height, config.target_fps + ) + + def _configure_scroll_helper(self) -> None: + """Configure ScrollHelper with current settings.""" + self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling) + self.scroll_helper.set_scroll_speed(self.config.scroll_speed) + self.scroll_helper.set_scroll_delay(self.config.scroll_delay) + self.scroll_helper.set_dynamic_duration_settings( + enabled=self.config.dynamic_duration_enabled, + min_duration=self.config.min_cycle_duration, + max_duration=self.config.max_cycle_duration, + buffer=0.1 # 10% buffer + ) + + def compose_scroll_content(self) -> bool: + """ + Compose content from stream manager into scrollable image. + + Returns: + True if composition successful + """ + try: + # Get all buffered content + images = self.stream_manager.get_all_content_for_composition() + + if not images: + logger.warning("No content available for composition") + return False + + # Add separator gaps between images + content_with_gaps = [] + for i, img in enumerate(images): + content_with_gaps.append(img) + + # Create scrolling image via ScrollHelper + self.scroll_helper.create_scrolling_image( + content_items=content_with_gaps, + item_gap=self.config.separator_width, + element_gap=0 + ) + + # Verify scroll image was created successfully + if not self.scroll_helper.cached_image: + logger.error("ScrollHelper failed to create cached image") + return False + + # Store reference to composed image + with self._buffer_lock: + self._active_scroll_image = self.scroll_helper.cached_image + + # Track which plugins are in this scroll (get safely via buffer status) + self._segments_in_scroll = self.stream_manager.get_active_plugin_ids() + + self.stats['composition_count'] += 1 + self._cycle_start_time = time.time() + self._cycle_complete = False + + logger.info( + "Composed scroll image: %dx%d, %d plugins, %d items", + self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0, + self.display_height, + len(self._segments_in_scroll), + len(images) + ) + + return True + + except Exception as e: + logger.error("Error composing scroll content: %s", e) + return False + + def render_frame(self) -> bool: + """ + Render a single frame to the display. + + Should be called at ~125 FPS (8ms intervals). + + Returns: + True if frame was rendered, False if no content + """ + frame_start = time.time() + + try: + if not self.scroll_helper.cached_image: + return False + + # Update scroll position + self.scroll_helper.update_scroll_position() + + # Check if cycle is complete + if self.scroll_helper.is_scroll_complete(): + if not self._cycle_complete: + self._cycle_complete = True + self.stats['scroll_cycles'] += 1 + logger.info( + "Scroll cycle complete after %.1fs", + time.time() - self._cycle_start_time + ) + + # Get visible portion + visible_frame = self.scroll_helper.get_visible_portion() + if not visible_frame: + return False + + # Render to display + self.display_manager.image = visible_frame + self.display_manager.update_display() + + # Update scrolling state + self.display_manager.set_scrolling_state(True) + + # Track statistics + self.stats['frames_rendered'] += 1 + frame_time = time.time() - frame_start + self._track_frame_time(frame_time) + + return True + + except Exception as e: + logger.error("Error rendering frame: %s", e) + return False + + def _track_frame_time(self, frame_time: float) -> None: + """Track frame timing for statistics.""" + self._frame_times.append(frame_time) # deque with maxlen auto-removes old entries + + if self._frame_times: + self.stats['avg_frame_time_ms'] = ( + sum(self._frame_times) / len(self._frame_times) * 1000 + ) + + def is_cycle_complete(self) -> bool: + """Check if current scroll cycle is complete.""" + return self._cycle_complete + + def should_recompose(self) -> bool: + """ + Check if scroll content should be recomposed. + + Returns True when: + - Cycle is complete and we should start fresh + - Staging buffer has new content + """ + if self._cycle_complete: + return True + + # Check if we need more content in the buffer + buffer_status = self.stream_manager.get_buffer_status() + if buffer_status['staging_count'] > 0: + return True + + return False + + def hot_swap_content(self) -> bool: + """ + Hot-swap to new composed content. + + Called when staging buffer has updated content. + Swaps atomically to prevent visual glitches. + + Returns: + True if swap occurred + """ + try: + # Process any pending updates + self.stream_manager.process_updates() + self.stream_manager.swap_buffers() + + # Recompose with updated content + if self.compose_scroll_content(): + self.stats['hot_swaps'] += 1 + logger.debug("Hot-swap completed") + return True + + return False + + except Exception as e: + logger.error("Error during hot-swap: %s", e) + return False + + def start_new_cycle(self) -> bool: + """ + Start a new scroll cycle. + + Fetches fresh content and recomposes. + + Returns: + True if new cycle started successfully + """ + # Reset scroll position + self.scroll_helper.reset_scroll() + self._cycle_complete = False + + # Refresh stream content + self.stream_manager.refresh() + + # Reinitialize stream if needed + if not self.stream_manager.get_buffer_status()['active_count']: + if not self.stream_manager.initialize(): + logger.warning("Failed to reinitialize stream for new cycle") + return False + + # Compose new scroll content + return self.compose_scroll_content() + + def get_current_scroll_info(self) -> Dict[str, Any]: + """Get current scroll state information.""" + scroll_info = self.scroll_helper.get_scroll_info() + return { + **scroll_info, + 'cycle_complete': self._cycle_complete, + 'plugins_in_scroll': self._segments_in_scroll, + 'stats': self.stats.copy(), + } + + def update_config(self, new_config: VegasModeConfig) -> None: + """ + Update render pipeline configuration. + + Args: + new_config: New configuration to apply + """ + old_fps = self.config.target_fps + self.config = new_config + self._frame_interval = new_config.get_frame_interval() + + # Reconfigure scroll helper + self._configure_scroll_helper() + + if old_fps != new_config.target_fps: + logger.info("FPS target updated: %d -> %d", old_fps, new_config.target_fps) + + def reset(self) -> None: + """Reset the render pipeline state.""" + self.scroll_helper.reset_scroll() + self.scroll_helper.clear_cache() + + with self._buffer_lock: + self._active_scroll_image = None + self._staging_scroll_image = None + + self._cycle_complete = False + self._segments_in_scroll = [] + self._frame_times = [] + + self.display_manager.set_scrolling_state(False) + + logger.info("RenderPipeline reset") + + def cleanup(self) -> None: + """Clean up resources.""" + self.reset() + self.display_manager.set_scrolling_state(False) + logger.debug("RenderPipeline cleanup complete") + + def get_dynamic_duration(self) -> float: + """Get the calculated dynamic duration for current content.""" + return float(self.scroll_helper.get_dynamic_duration()) diff --git a/src/vegas_mode/stream_manager.py b/src/vegas_mode/stream_manager.py new file mode 100644 index 000000000..70e6b186d --- /dev/null +++ b/src/vegas_mode/stream_manager.py @@ -0,0 +1,406 @@ +""" +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. +""" + +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 + +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 + fetched_at: float = field(default_factory=time.time) + is_stale: bool = False + + @property + def image_count(self) -> int: + return len(self.images) + + +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. + + Rebuilds content for updated plugins in staging buffer, + then swaps buffers atomically. + """ + with self._buffer_lock: + if not self._pending_updates: + return + + updated_plugins = list(self._pending_updates.keys()) + self._pending_updates.clear() + + # Rebuild content for updated plugins + for plugin_id in updated_plugins: + self._refresh_plugin_content(plugin_id) + + logger.debug("Processed 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: + # Merge staging into active at appropriate positions + # For simplicity, append staging content + self._active_buffer.extend(self._staging_buffer) + self._staging_buffer.clear() + 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.""" + # Get all enabled plugins + available_plugins = [] + + if hasattr(self.plugin_manager, 'loaded_plugins'): + for plugin_id, plugin in self.plugin_manager.loaded_plugins.items(): + if hasattr(plugin, 'enabled') and plugin.enabled: + # Check vegas content type - skip 'none' + content_type = self.plugin_adapter.get_content_type(plugin, plugin_id) + if content_type != 'none': + available_plugins.append(plugin_id) + + # Apply ordering from config + self._ordered_plugins = self.config.get_ordered_plugins(available_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 + + 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 + + num_plugins = len(self._ordered_plugins) + + for _ in range(count): + if len(self._active_buffer) >= self.config.buffer_ahead + 1: + break + + # Ensure index is valid (guard against empty list) + 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) + + # 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: + # Get plugin instance + if not hasattr(self.plugin_manager, 'loaded_plugins'): + return None + + plugin = self.plugin_manager.loaded_plugins.get(plugin_id) + if not plugin: + logger.warning("Plugin %s not found", plugin_id) + return None + + # Get content via adapter + images = self.plugin_adapter.get_content(plugin, plugin_id) + if not images: + logger.debug("No content from plugin %s", 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 + ) + + self.stats['segments_fetched'] += 1 + logger.debug( + "Fetched segment from %s: %d images, %dpx total", + plugin_id, len(images), total_width + ) + + return segment + + except Exception as e: + logger.error("Error fetching content from %s: %s", plugin_id, e) + 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. + + Returns: + List of all images in buffer order + """ + all_images = [] + with self._buffer_lock: + for segment in self._active_buffer: + all_images.extend(segment.images) + return all_images + + 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") diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 9998cc22c..cf2dd5da5 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -463,6 +463,50 @@ def save_main_config(): current_config['display']['dynamic_duration'] = {} current_config['display']['dynamic_duration']['max_duration_seconds'] = int(data['max_dynamic_duration_seconds']) + # Handle Vegas scroll mode settings + vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width', + 'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins'] + + if any(k in data for k in vegas_fields): + if 'display' not in current_config: + current_config['display'] = {} + if 'vegas_scroll' not in current_config['display']: + current_config['display']['vegas_scroll'] = {} + + vegas_config = current_config['display']['vegas_scroll'] + + # Handle enabled checkbox + vegas_config['enabled'] = data.get('vegas_scroll_enabled', False) + + # Handle numeric settings + if 'vegas_scroll_speed' in data: + vegas_config['scroll_speed'] = int(data['vegas_scroll_speed']) + if 'vegas_separator_width' in data: + vegas_config['separator_width'] = int(data['vegas_separator_width']) + if 'vegas_target_fps' in data: + vegas_config['target_fps'] = int(data['vegas_target_fps']) + if 'vegas_buffer_ahead' in data: + vegas_config['buffer_ahead'] = int(data['vegas_buffer_ahead']) + + # Handle plugin order and exclusions (JSON arrays) + if 'vegas_plugin_order' in data: + try: + if isinstance(data['vegas_plugin_order'], str): + vegas_config['plugin_order'] = json.loads(data['vegas_plugin_order']) + else: + vegas_config['plugin_order'] = data['vegas_plugin_order'] + except (json.JSONDecodeError, TypeError): + vegas_config['plugin_order'] = [] + + if 'vegas_excluded_plugins' in data: + try: + if isinstance(data['vegas_excluded_plugins'], str): + vegas_config['excluded_plugins'] = json.loads(data['vegas_excluded_plugins']) + else: + vegas_config['excluded_plugins'] = data['vegas_excluded_plugins'] + except (json.JSONDecodeError, TypeError): + vegas_config['excluded_plugins'] = [] + # Handle display durations duration_fields = [k for k in data.keys() if k.endswith('_duration') or k in ['default_duration', 'transition_duration']] if duration_fields: diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html index 6a5025582..8c73bc744 100644 --- a/web_interface/templates/v3/partials/display.html +++ b/web_interface/templates/v3/partials/display.html @@ -238,6 +238,93 @@

Dynamic Duration

+ +
+
+
+

+ Vegas Scroll Mode +

+

Combine all plugin content into one continuous scrolling ticker display.

+
+ +
+ + +
+
+
+ +
+ + {{ main_config.display.get('vegas_scroll', {}).get('scroll_speed', 50) }} +
+

Speed of the scrolling ticker (10-200 px/s)

+
+ +
+ + +

Gap between plugin content blocks (0-128 px)

+
+
+ +
+
+ + +

Higher FPS = smoother scroll, more CPU usage

+
+ +
+ + +

How many plugins to pre-load ahead

+
+
+ + +
+

Plugin Order

+

Drag to reorder plugins. Uncheck to exclude from Vegas scroll.

+
+ +

Loading plugins...

+
+ + +
+
+
+