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)
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'):
if not (hasattr(plugin_instance, 'has_live_priority')
and hasattr(plugin_instance, 'has_live_content')):
continue
try:
if plugin_instance.has_live_priority() and plugin_instance.has_live_content():
# Get the specific live mode from the plugin if available
if not (plugin_instance.has_live_priority()
and plugin_instance.has_live_content()):
continue
resolved = []
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:
for suggested_mode in (plugin_instance.get_live_modes() or []):
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
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

View File

@@ -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."""