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 <noreply@anthropic.com>
This commit is contained in:
Ron Pierce
2026-06-15 11:07:21 -07:00
committed by GitHub
parent 974d7ea57a
commit d297dd6217
2 changed files with 162 additions and 24 deletions

View File

@@ -1505,30 +1505,68 @@ class DisplayController:
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode) logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
self._live_resume_index = None self._live_resume_index = None
def _check_live_priority(self): def _collect_live_modes(self):
""" """Return every currently live-priority mode, in registration order.
Check all plugins for live priority content.
Returns the mode that should be displayed if live content is found, None otherwise. 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(): for mode_name, plugin_instance in self.plugin_modes.items():
if hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content'): if not (hasattr(plugin_instance, 'has_live_priority')
try: and hasattr(plugin_instance, 'has_live_content')):
if plugin_instance.has_live_priority() and plugin_instance.has_live_content(): continue
# Get the specific live mode from the plugin if available try:
if hasattr(plugin_instance, 'get_live_modes'): if not (plugin_instance.has_live_priority()
live_modes = plugin_instance.get_live_modes() and plugin_instance.has_live_content()):
if live_modes and len(live_modes) > 0: continue
# Verify the mode actually exists before returning it resolved = []
for suggested_mode in live_modes: if hasattr(plugin_instance, 'get_live_modes'):
if suggested_mode in self.plugin_modes: for suggested_mode in (plugin_instance.get_live_modes() or []):
return suggested_mode if suggested_mode in self.plugin_modes:
# If suggested modes don't exist, fall through to check current mode resolved.append(suggested_mode)
# Fallback: if this mode ends with _live, return it if not resolved and mode_name.endswith('_live'):
if mode_name.endswith('_live'): resolved.append(mode_name)
return mode_name for m in resolved:
except Exception as e: if m not in seen:
logger.warning("Error checking live priority for %s: %s", mode_name, e) seen.add(m)
return None 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): def run(self):
"""Run the display controller, switching between displays.""" """Run the display controller, switching between displays."""
@@ -1689,9 +1727,11 @@ class DisplayController:
# Display failed, clear the status and continue normally # Display failed, clear the status and continue normally
wifi_status_data = None 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: 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) self._apply_live_priority(live_priority_mode)
# Vegas scroll mode - continuous ticker across all plugins # Vegas scroll mode - continuous ticker across all plugins

View File

@@ -214,6 +214,104 @@ class TestDisplayControllerLivePriority:
assert controller.current_mode_index == 1 assert controller.current_mode_index == 1
assert controller.current_display_mode == "b" 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: class TestDisplayControllerDynamicDuration:
"""Test dynamic duration handling.""" """Test dynamic duration handling."""