mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-19 19:18:38 +00:00
* feat(sync): multi-display wireless sync — extend scrolling across two LED matrices Adds a leader/follower sync system that extends Vegas scroll mode content continuously across two physically adjacent LED matrix units over WiFi. Architecture: - Leader broadcasts scroll position via UDP at ~90fps; follower renders the offset slice of the same image at 60fps using dead reckoning to absorb UDP jitter (smooth, stutter-free motion) - At each cycle transition the leader sends the composed scroll image via TCP (PNG-compressed ~15–40KB) so both displays render pixel-identical content regardless of plugin data timing differences - Auto-discovery via UDP subnet broadcast — no IP configuration required - Heartbeat watchdog (6s timeout) falls back to standalone if peer goes offline Key files: - src/common/sync_manager.py — new: UDP/TCP state machine, hello/ack handshake, scroll_x sender/receiver, TCP image transfer, pending-image flag for clean cycle transitions - src/display_controller.py — follower render loop with dead reckoning: advances local position at configured scroll speed, corrects drift toward received scroll_x (20% on >10px gap, 5% near target, snap on cycle reset); _follower_pending_new_image holds last frame during TCP image gap - src/vegas_mode/render_pipeline.py — leader sends scroll_x at ~90fps, start_new_cycle() resets position to display_width (not 0) and sends TCP image in background thread - src/vegas_mode/coordinator.py — set_sync_manager() / set_update_callback() wiring; defers hot-swap recompose while sync is active - web_interface/blueprints/api_v3.py — sync config save endpoint, GET /api/v3/sync/status for live status polling - web_interface/templates/v3/partials/display.html — Multi-Display Sync section: role selector (Standalone/Leader/Follower), position (Left/Right of leader, follower only), UDP port, live status indicator - config/config.template.json — sync block: role, port, follower_position Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): address PR review findings - sync_manager: replace Optional[callable] with proper Callable types from typing; tighten set_on_new_cycle/set_on_scroll_image/set_on_follower_connected signatures to match their actual callback signatures - sync_manager: log a one-shot warning when send_frame produces a packet exceeding the 65000-byte UDP cap instead of silently dropping it - display_controller: correct stale comment in _send_follower_frame (was "30fps / PNG encode/decode"; actual behavior is ~90fps raw RGB) - display.html: guard setInterval with window.syncStatusInterval to prevent duplicate pollers if the script runs more than once - display.html: replace innerHTML with DOM node creation + textContent for status icon/text to avoid inserting API-derived values via innerHTML Skip: time.time() → monotonic and self.config staleness are pre-existing issues not introduced by this PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): address second round of PR review findings - sync_manager: guard TCP image receive against OOM — validate length against 10 MB cap before allocating; log and close on invalid length - display_controller: _follower_gated_update now allows update_display() through when the leader is offline (is_follower_active() == False) so the display recovers normally when falling back to standalone mode - coordinator: normalize a standalone SyncManager to None in set_sync_manager() so the render pipeline never treats a no-op manager as an active one - coordinator: derive _UPDATE_TICK_FRAMES from target_fps * 4 instead of the hardcoded 500 so the ~4s cadence holds at any configured FPS - render_pipeline: replace bare except/pass on blank-frame push with logger.exception() so failures are visible in logs Skip: config.template.json comments — JSON does not support inline comments. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): address third round of PR review findings - sync_manager: use 'with socket.socket(...)' in send_scroll_image so the TCP socket is always closed even if connect/sendall raises - sync_manager: add _scroll_image_lock to serialize all reads/writes to _on_scroll_image and _pending_scroll_image between _image_server_loop and set_on_scroll_image, eliminating the lost-delivery race; callback is invoked outside the lock to avoid holding it during user code - sync_manager: validate scroll image dimensions (max 100000×256) and catch DecompressionBombError before img.load() in _image_server_loop - sync_manager: log socket close exceptions at debug level in stop() instead of silently passing - sync_manager: replace hardcoded /tmp/ with tempfile.gettempdir() for STATUS_FILE (atomic write was already in place) - sync_manager: check _RAW_MAGIC first in _follower_recv_loop routing so magic-tagged frames are always identified correctly regardless of size Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): address fourth round of PR review findings - sync_manager: log INCOMPATIBLE error only on state transition (guard with prev_state != LeaderState.INCOMPATIBLE) so repeated hello packets from an incompatible follower don't spam the log - sync_manager: replace O(n²) bytes concatenation in TCP image receive loop with bytearray + extend() for linear-time accumulation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): suppress Codacy false positives - display_controller: rename local var 'sh' to 'scroll_h' so Codacy's pattern matcher doesn't confuse it with the 'sh' shell library - sync_manager: add '# nosec B104' to all socket.bind("") calls — binding to all interfaces is intentional (UDP broadcast reception and TCP image server must accept connections from any local interface) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): add nosec B104 to socket creation lines for Codacy Codacy attributes the bind-to-all-interfaces finding to the socket.socket() creation lines (140, 439) rather than the .bind() calls. Added # nosec B104 there too so the suppression is seen at the line Codacy reports. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
730 lines
26 KiB
Python
730 lines
26 KiB
Python
"""
|
|
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.
|
|
|
|
Supports three display modes per plugin:
|
|
- SCROLL: Content scrolls continuously within the stream
|
|
- FIXED_SEGMENT: Fixed block that scrolls by with other content
|
|
- STATIC: Scroll pauses, plugin displays for its duration, then resumes
|
|
"""
|
|
|
|
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
|
|
from src.plugin_system.base_plugin import VegasDisplayMode
|
|
|
|
if TYPE_CHECKING:
|
|
from src.plugin_system.plugin_manager import PluginManager
|
|
from src.plugin_system.base_plugin import BasePlugin
|
|
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
|
|
|
|
# Interrupt checker for yielding control back to display controller
|
|
self._interrupt_check: Optional[Callable[[], bool]] = None
|
|
self._interrupt_check_interval: int = 10 # Check every N frames
|
|
|
|
# Plugin update callback — fired from a background thread inside the loop
|
|
# so the main loop's _tick_plugin_updates() finds nothing due when Vegas
|
|
# returns, eliminating the inter-iteration frozen-frame gap.
|
|
self._update_callback: Optional[Callable[[], None]] = None
|
|
self._update_tick_running: bool = False
|
|
|
|
# Config update tracking
|
|
self._config_version = 0
|
|
self._pending_config_update = False
|
|
self._pending_config: Optional[Dict[str, Any]] = None
|
|
|
|
# Static pause handling
|
|
self._static_pause_active = False
|
|
self._static_pause_plugin: Optional['BasePlugin'] = None
|
|
self._static_pause_start: Optional[float] = None
|
|
self._saved_scroll_position: Optional[int] = None
|
|
|
|
# Track which plugins should use STATIC mode (pause scroll)
|
|
self._static_mode_plugins: set = set()
|
|
|
|
# Statistics
|
|
self.stats = {
|
|
'total_runtime_seconds': 0.0,
|
|
'cycles_completed': 0,
|
|
'interruptions': 0,
|
|
'config_updates': 0,
|
|
'static_pauses': 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_sync_manager(self, sync_manager, follower_position: str = "left") -> None:
|
|
"""
|
|
Attach a DisplaySyncManager so Vegas mode sends the follower's portion
|
|
of the ticker to the second display on every rendered frame.
|
|
|
|
Args:
|
|
sync_manager: DisplaySyncManager instance, or None to disable sync
|
|
follower_position: "left" (default) or "right" — physical position of
|
|
the follower display relative to the leader
|
|
"""
|
|
if self.render_pipeline:
|
|
# Don't expose a standalone (no-op) manager to the pipeline — treat it as None
|
|
if sync_manager is not None and hasattr(sync_manager, 'role'):
|
|
from src.common.sync_manager import SyncRole
|
|
if sync_manager.role == SyncRole.STANDALONE:
|
|
sync_manager = None
|
|
self.render_pipeline.sync_manager = sync_manager
|
|
self.render_pipeline.sync_follower_left = (follower_position == "left")
|
|
|
|
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 set_interrupt_checker(
|
|
self,
|
|
checker: Callable[[], bool],
|
|
check_interval: int = 10
|
|
) -> None:
|
|
"""
|
|
Set the callback for checking if Vegas should yield control.
|
|
|
|
This allows the display controller to interrupt Vegas mode
|
|
when on-demand, wifi status, or other priority events occur.
|
|
|
|
Args:
|
|
checker: Callable that returns True if Vegas should yield
|
|
check_interval: Check every N frames (default 10)
|
|
"""
|
|
self._interrupt_check = checker
|
|
self._interrupt_check_interval = max(1, check_interval)
|
|
|
|
def set_update_callback(self, callback: Callable[[], None]) -> None:
|
|
"""
|
|
Set a callback for running plugin updates from inside the Vegas loop.
|
|
|
|
Fired in a daemon background thread every ~4 s so plugin data stays
|
|
fresh without blocking the render loop. The main loop's
|
|
_tick_plugin_updates() then finds all intervals already satisfied and
|
|
returns immediately, collapsing the inter-iteration gap to <1 ms.
|
|
|
|
Args:
|
|
callback: Callable with no arguments (typically _tick_plugin_updates)
|
|
"""
|
|
self._update_callback = callback
|
|
|
|
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
|
|
|
|
self.display_manager.set_scrolling_state(True)
|
|
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.
|
|
|
|
Handles three display modes:
|
|
- SCROLL/FIXED_SEGMENT: Continue normal scroll rendering
|
|
- STATIC: Pause scroll, display plugin, resume on completion
|
|
|
|
Returns:
|
|
True if iteration completed normally, False if interrupted
|
|
"""
|
|
if not self.is_active:
|
|
if not self.start():
|
|
return False
|
|
|
|
# Update static mode plugin list on iteration start
|
|
self._update_static_mode_plugins()
|
|
|
|
frame_interval = self.vegas_config.get_frame_interval()
|
|
duration = self.render_pipeline.get_dynamic_duration()
|
|
start_time = time.time()
|
|
frame_count = 0
|
|
fps_log_interval = 5.0 # Log FPS every 5 seconds
|
|
last_fps_log_time = start_time
|
|
fps_frame_count = 0
|
|
|
|
logger.info("Starting Vegas iteration for %.1fs", duration)
|
|
|
|
while True:
|
|
# Check for STATIC mode plugin that should pause scroll
|
|
static_plugin = self._check_static_plugin_trigger()
|
|
if static_plugin:
|
|
if not self._handle_static_pause(static_plugin):
|
|
# Static pause was interrupted
|
|
return False
|
|
# After static pause, skip this segment and continue
|
|
self.stream_manager.get_next_segment() # Consume the segment
|
|
continue
|
|
|
|
# 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)
|
|
|
|
# Increment frame count and check for interrupt periodically
|
|
frame_count += 1
|
|
fps_frame_count += 1
|
|
|
|
# Periodic FPS logging
|
|
current_time = time.time()
|
|
if current_time - last_fps_log_time >= fps_log_interval:
|
|
fps = fps_frame_count / (current_time - last_fps_log_time)
|
|
logger.info(
|
|
"Vegas FPS: %.1f (target: %d, frames: %d)",
|
|
fps, self.vegas_config.target_fps, fps_frame_count
|
|
)
|
|
last_fps_log_time = current_time
|
|
fps_frame_count = 0
|
|
|
|
if (self._interrupt_check and
|
|
frame_count % self._interrupt_check_interval == 0):
|
|
try:
|
|
if self._interrupt_check():
|
|
logger.debug(
|
|
"Vegas interrupted by callback after %d frames",
|
|
frame_count
|
|
)
|
|
return False
|
|
except Exception:
|
|
# Log but don't let interrupt check errors stop Vegas
|
|
logger.exception("Interrupt check failed")
|
|
|
|
# Fire plugin update tick in a background thread every ~4 s.
|
|
# Running it here (rather than only between iterations) means the
|
|
# main loop's _tick_plugin_updates() finds all intervals already
|
|
# satisfied on return, so the inter-iteration gap is <1 ms and the
|
|
# display never shows a frozen frame between iterations.
|
|
_UPDATE_TICK_FRAMES = max(1, int(self.vegas_config.target_fps * 4)) # every 4 s regardless of FPS
|
|
if (self._update_callback and
|
|
frame_count % _UPDATE_TICK_FRAMES == 0 and
|
|
not self._update_tick_running):
|
|
self._update_tick_running = True
|
|
def _run_tick(cb=self._update_callback):
|
|
try:
|
|
cb()
|
|
finally:
|
|
self._update_tick_running = False
|
|
threading.Thread(
|
|
target=_run_tick, daemon=True, name="vegas-plugin-tick"
|
|
).start()
|
|
|
|
# Check elapsed time
|
|
elapsed = time.time() - start_time
|
|
if elapsed >= duration:
|
|
break
|
|
|
|
# NOTE: do NOT break on is_cycle_complete() here.
|
|
# When multi-display sync is active, breaking exits run_iteration()
|
|
# which causes a 2-3s delay before start_new_cycle() is called on
|
|
# the next run_iteration(). During that gap the scroll advances into
|
|
# the pre-roll zone, then start_new_cycle() resets it — producing a
|
|
# second visible jump on the follower display ~2.5s after the first.
|
|
#
|
|
# Instead, run_frame() handles cycle completion directly (it calls
|
|
# start_new_cycle() in the very next frame, 8ms later), collapsing
|
|
# the two events into a single clean transition.
|
|
#
|
|
# Without sync, the iteration now runs to its full duration and may
|
|
# cycle content multiple times within one iteration — acceptable for
|
|
# a continuous ticker.
|
|
|
|
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:
|
|
logger.exception("Error checking live priority")
|
|
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."""
|
|
# Atomically grab pending config and clear it to avoid losing concurrent updates
|
|
with self._state_lock:
|
|
if self._pending_config is None:
|
|
self._pending_config_update = False
|
|
return
|
|
pending_config = self._pending_config
|
|
self._pending_config = None # Clear while holding lock
|
|
|
|
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)
|
|
self.stream_manager.config = new_vegas_config
|
|
|
|
# Force refresh of stream manager to pick up plugin_order/buffer changes
|
|
self.stream_manager._last_refresh = 0
|
|
self.stream_manager.refresh()
|
|
|
|
# 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:
|
|
logger.exception("Error applying config update")
|
|
|
|
finally:
|
|
# Only clear update flag if no new config arrived during processing
|
|
with self._state_lock:
|
|
if self._pending_config is None:
|
|
self._pending_config_update = False
|
|
|
|
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, 'plugins'):
|
|
available = list(self.plugin_manager.plugins.keys())
|
|
return self.vegas_config.get_ordered_plugins(available)
|
|
return []
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Static pause handling (for STATIC display mode)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _check_static_plugin_trigger(self) -> Optional['BasePlugin']:
|
|
"""
|
|
Check if a STATIC mode plugin should take over display.
|
|
|
|
Called during iteration to detect when scroll should pause
|
|
for a static plugin display.
|
|
|
|
Returns:
|
|
Plugin instance if static pause should begin, None otherwise
|
|
"""
|
|
# Get the next plugin that would be displayed
|
|
next_segment = self.stream_manager.peek_next_segment()
|
|
if not next_segment:
|
|
return None
|
|
|
|
plugin_id = next_segment.plugin_id
|
|
plugin = self.plugin_manager.get_plugin(plugin_id)
|
|
|
|
if not plugin:
|
|
return None
|
|
|
|
# Check if this plugin is configured for STATIC mode
|
|
try:
|
|
display_mode = plugin.get_vegas_display_mode()
|
|
if display_mode == VegasDisplayMode.STATIC:
|
|
return plugin
|
|
except (AttributeError, TypeError):
|
|
logger.exception("Error checking vegas mode for %s", plugin_id)
|
|
|
|
return None
|
|
|
|
def _handle_static_pause(self, plugin: 'BasePlugin') -> bool:
|
|
"""
|
|
Handle a static pause - scroll pauses while plugin displays.
|
|
|
|
Args:
|
|
plugin: The STATIC mode plugin to display
|
|
|
|
Returns:
|
|
True if completed normally, False if interrupted
|
|
"""
|
|
plugin_id = plugin.plugin_id
|
|
|
|
with self._state_lock:
|
|
if self._static_pause_active:
|
|
logger.warning("Static pause already active")
|
|
return True
|
|
|
|
# Save current scroll position for smooth resume
|
|
self._saved_scroll_position = self.render_pipeline.get_scroll_position()
|
|
self._static_pause_active = True
|
|
self._static_pause_plugin = plugin
|
|
self._static_pause_start = time.time()
|
|
self.stats['static_pauses'] += 1
|
|
|
|
logger.info("Static pause started for plugin: %s", plugin_id)
|
|
|
|
# Stop scrolling indicator
|
|
self.display_manager.set_scrolling_state(False)
|
|
|
|
try:
|
|
# Display the plugin using its standard display() method
|
|
plugin.display(force_clear=True)
|
|
self.display_manager.update_display()
|
|
|
|
# Wait for the plugin's display duration
|
|
duration = plugin.get_display_duration()
|
|
start = time.time()
|
|
|
|
while time.time() - start < duration:
|
|
# Check for interruptions
|
|
if self._should_stop:
|
|
logger.info("Static pause interrupted by stop request")
|
|
return False
|
|
|
|
if self._check_live_priority():
|
|
logger.info("Static pause interrupted by live priority")
|
|
return False
|
|
|
|
# Yield immediately if multi-display follower mode becomes active
|
|
if self._interrupt_check and self._interrupt_check():
|
|
logger.info("Static pause interrupted by sync follower mode")
|
|
return False
|
|
|
|
# Sleep in small increments to remain responsive
|
|
time.sleep(0.1)
|
|
|
|
logger.info(
|
|
"Static pause completed for %s after %.1fs",
|
|
plugin_id, time.time() - start
|
|
)
|
|
|
|
except Exception:
|
|
logger.exception("Error during static pause for %s", plugin_id)
|
|
return False
|
|
|
|
finally:
|
|
self._end_static_pause()
|
|
|
|
return True
|
|
|
|
def _end_static_pause(self) -> None:
|
|
"""End static pause and restore scroll state."""
|
|
should_resume_scrolling = False
|
|
|
|
with self._state_lock:
|
|
# Only resume scrolling if we weren't interrupted
|
|
was_active = self._static_pause_active
|
|
should_resume_scrolling = (
|
|
was_active and
|
|
not self._should_stop and
|
|
not self._live_priority_active
|
|
)
|
|
|
|
# Clear pause state
|
|
self._static_pause_active = False
|
|
self._static_pause_plugin = None
|
|
self._static_pause_start = None
|
|
|
|
# Restore scroll position if we're resuming
|
|
if should_resume_scrolling and self._saved_scroll_position is not None:
|
|
self.render_pipeline.set_scroll_position(self._saved_scroll_position)
|
|
self._saved_scroll_position = None
|
|
|
|
# Only resume scrolling state if not interrupted
|
|
if should_resume_scrolling:
|
|
self.display_manager.set_scrolling_state(True)
|
|
logger.debug("Static pause ended, scroll resumed")
|
|
else:
|
|
logger.debug("Static pause ended (interrupted, not resuming scroll)")
|
|
|
|
def _update_static_mode_plugins(self) -> None:
|
|
"""Update the set of plugins using STATIC display mode."""
|
|
self._static_mode_plugins.clear()
|
|
|
|
for plugin_id in self.get_ordered_plugins():
|
|
plugin = self.plugin_manager.get_plugin(plugin_id)
|
|
if plugin:
|
|
try:
|
|
mode = plugin.get_vegas_display_mode()
|
|
if mode == VegasDisplayMode.STATIC:
|
|
self._static_mode_plugins.add(plugin_id)
|
|
except Exception:
|
|
logger.exception(
|
|
"Error getting vegas display mode for plugin %s",
|
|
plugin_id
|
|
)
|
|
|
|
if self._static_mode_plugins:
|
|
logger.info(
|
|
"Static mode plugins: %s",
|
|
', '.join(self._static_mode_plugins)
|
|
)
|
|
|
|
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")
|