From d297dd621703c4e09f30cb45da44ea373573c90a Mon Sep 17 00:00:00 2001 From: Ron Pierce Date: Mon, 15 Jun 2026 11:07:21 -0700 Subject: [PATCH] feat(display-controller): round-robin between simultaneous live-priority games (#372) _check_live_priority() was stateless first-match-wins: it returned the first plugin in registration order with live content, and the post-dwell hold pinned the carousel to it, so when two games were live at once (e.g. a baseball game and a soccer match) the second never showed until the first ended. Add _collect_live_modes() (all currently-live modes, deduped, in registration order) and give _check_live_priority an 'advance' flag. The main rotation calls it with advance=True, which returns the live mode after the one currently shown -- using current_display_mode as the cursor -- so each dwell advances to the next live game and they take turns. The Vegas coordinator and the vegas-active check keep the default non-advancing peek (advance=False), so they only report whether any game is live without spinning the cursor. should_rotate and _apply_live_priority are unchanged; a single live game still holds as before. Adds regression tests to TestDisplayControllerLivePriority. Co-authored-by: Claude Opus 4.8 --- src/display_controller.py | 88 +++++++++++++++++++++-------- test/test_display_controller.py | 98 +++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 24 deletions(-) diff --git a/src/display_controller.py b/src/display_controller.py index 0a55662d..4ebe580a 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -1505,30 +1505,68 @@ class DisplayController: logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode) self._live_resume_index = None - def _check_live_priority(self): - """ - Check all plugins for live priority content. - Returns the mode that should be displayed if live content is found, None otherwise. + def _collect_live_modes(self): + """Return every currently live-priority mode, in registration order. + + Scans all registered plugin modes; for each plugin that has live + priority *and* live content, collects the specific live mode(s) it + reports via get_live_modes() (only those actually registered), falling + back to the scanned mode name when it ends in '_live'. Deduplicated, + preserving order. A plugin registered under several mode keys (the + sports plugins register one per league) contributes each live mode once. """ + live = [] + seen = set() for mode_name, plugin_instance in self.plugin_modes.items(): - if hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content'): - try: - if plugin_instance.has_live_priority() and plugin_instance.has_live_content(): - # Get the specific live mode from the plugin if available - if hasattr(plugin_instance, 'get_live_modes'): - live_modes = plugin_instance.get_live_modes() - if live_modes and len(live_modes) > 0: - # Verify the mode actually exists before returning it - for suggested_mode in live_modes: - if suggested_mode in self.plugin_modes: - return suggested_mode - # If suggested modes don't exist, fall through to check current mode - # Fallback: if this mode ends with _live, return it - if mode_name.endswith('_live'): - return mode_name - except Exception as e: - logger.warning("Error checking live priority for %s: %s", mode_name, e) - return None + if not (hasattr(plugin_instance, 'has_live_priority') + and hasattr(plugin_instance, 'has_live_content')): + continue + try: + if not (plugin_instance.has_live_priority() + and plugin_instance.has_live_content()): + continue + resolved = [] + if hasattr(plugin_instance, 'get_live_modes'): + for suggested_mode in (plugin_instance.get_live_modes() or []): + if suggested_mode in self.plugin_modes: + resolved.append(suggested_mode) + if not resolved and mode_name.endswith('_live'): + resolved.append(mode_name) + for m in resolved: + if m not in seen: + seen.add(m) + live.append(m) + except Exception as e: + logger.warning("Error checking live priority for %s: %s", mode_name, e) + return live + + def _check_live_priority(self, advance=False): + """Return the live-priority mode to display, or None if nothing is live. + + When several plugins report live content at once (e.g. a baseball game + and a soccer match), this round-robins between them so the display + alternates each dwell instead of pinning to whichever plugin is first in + registration order. + + advance=False (default): a non-advancing peek — returns the live mode + already on screen if it is still live, otherwise the first live mode. + Used by the Vegas coordinator and the vegas-active check, which only + need to know whether *any* game is live (and must not spin the cursor). + + advance=True: the rotation pick — returns the live mode *after* the one + currently shown, so each dwell advances to the next live game. The + currently-displayed mode is the cursor, so this stays correct as games + start and end (no separate index to keep in sync). + """ + live_modes = self._collect_live_modes() + if not live_modes: + return None + if self.current_display_mode in live_modes: + if advance: + idx = live_modes.index(self.current_display_mode) + return live_modes[(idx + 1) % len(live_modes)] + return self.current_display_mode + return live_modes[0] def run(self): """Run the display controller, switching between displays.""" @@ -1689,9 +1727,11 @@ class DisplayController: # Display failed, clear the status and continue normally wifi_status_data = None - # Check for live priority content and switch to it immediately + # Check for live priority content and switch to it immediately. + # advance=True so multiple simultaneously-live games take turns + # (round-robin) instead of pinning to the first plugin. if not self.on_demand_active and not wifi_status_data: - live_priority_mode = self._check_live_priority() + live_priority_mode = self._check_live_priority(advance=True) self._apply_live_priority(live_priority_mode) # Vegas scroll mode - continuous ticker across all plugins diff --git a/test/test_display_controller.py b/test/test_display_controller.py index bda53efe..34d5ce26 100644 --- a/test/test_display_controller.py +++ b/test/test_display_controller.py @@ -214,6 +214,104 @@ class TestDisplayControllerLivePriority: assert controller.current_mode_index == 1 assert controller.current_display_mode == "b" + # --- Round-robin between multiple simultaneous live games -------------- + + @staticmethod + def _live_plugin(live_modes): + """A mock plugin that is live and reports the given live mode names.""" + p = MagicMock() + p.has_live_priority = MagicMock(return_value=True) + p.has_live_content = MagicMock(return_value=True) + p.get_live_modes = MagicMock(return_value=list(live_modes)) + return p + + def test_collect_live_modes_dedupes_multi_mode_plugin(self, test_display_controller): + """A sports plugin registered under several mode keys (one per league) + contributes each live mode once, in registration order; plugins with no + live content are skipped.""" + controller = test_display_controller + baseball = self._live_plugin(["baseball_live"]) + soccer = self._live_plugin(["soccer_fifa.world_live"]) + idle = MagicMock() + idle.has_live_priority = MagicMock(return_value=True) + idle.has_live_content = MagicMock(return_value=False) + controller.plugin_modes = { + "baseball_live": baseball, + "baseball_recent": baseball, + "soccer_fifa.world_live": soccer, + "soccer_usa.1_live": soccer, + "soccer_recent": soccer, + "clock": idle, + } + assert controller._collect_live_modes() == [ + "baseball_live", "soccer_fifa.world_live" + ] + + def test_round_robin_alternates_between_simultaneous_live_games(self, test_display_controller): + """Regression: with two games live at once, the live-priority pick + round-robins each dwell instead of pinning to the first plugin in + registration order (the bug where a baseball game hid a live World Cup + match).""" + controller = test_display_controller + baseball = self._live_plugin(["baseball_live"]) + soccer = self._live_plugin(["soccer_fifa.world_live"]) + controller.plugin_modes = { + "baseball_live": baseball, + "soccer_fifa.world_live": soccer, + } + # First entry into live priority from an ambient mode -> first live game. + controller.current_display_mode = "clock" + assert controller._check_live_priority(advance=True) == "baseball_live" + # The controller switches to it; the next dwell advances to the other. + controller.current_display_mode = "baseball_live" + assert controller._check_live_priority(advance=True) == "soccer_fifa.world_live" + # And wraps back again. + controller.current_display_mode = "soccer_fifa.world_live" + assert controller._check_live_priority(advance=True) == "baseball_live" + + def test_single_live_game_holds_without_flipping(self, test_display_controller): + """One live game: advancing returns the same mode, so the hold is stable.""" + controller = test_display_controller + controller.plugin_modes = {"baseball_live": self._live_plugin(["baseball_live"])} + controller.current_display_mode = "baseball_live" + assert controller._check_live_priority(advance=True) == "baseball_live" + + def test_non_advancing_peek_does_not_rotate(self, test_display_controller): + """The default (advance=False) peek used by the Vegas coordinator must + not spin the cursor: it returns the live mode already on screen.""" + controller = test_display_controller + controller.plugin_modes = { + "baseball_live": self._live_plugin(["baseball_live"]), + "soccer_fifa.world_live": self._live_plugin(["soccer_fifa.world_live"]), + } + controller.current_display_mode = "soccer_fifa.world_live" + assert controller._check_live_priority() == "soccer_fifa.world_live" + assert controller._check_live_priority() == "soccer_fifa.world_live" + # From an ambient mode the peek reports the first live game (truthy). + controller.current_display_mode = "clock" + assert controller._check_live_priority() == "baseball_live" + + def test_no_live_content_returns_none(self, test_display_controller): + controller = test_display_controller + idle = MagicMock() + idle.has_live_priority = MagicMock(return_value=True) + idle.has_live_content = MagicMock(return_value=False) + controller.plugin_modes = {"clock": idle} + controller.current_display_mode = "clock" + assert controller._check_live_priority(advance=True) is None + + def test_fallback_to_mode_name_when_get_live_modes_unhelpful(self, test_display_controller): + """A live plugin whose get_live_modes returns nothing registered falls + back to its own '_live' mode name (legacy behavior preserved).""" + controller = test_display_controller + legacy = MagicMock() + legacy.has_live_priority = MagicMock(return_value=True) + legacy.has_live_content = MagicMock(return_value=True) + legacy.get_live_modes = MagicMock(return_value=["unregistered_mode"]) + controller.plugin_modes = {"hockey_live": legacy} + controller.current_display_mode = "clock" + assert controller._check_live_priority(advance=True) == "hockey_live" + class TestDisplayControllerDynamicDuration: """Test dynamic duration handling."""