From bffdfed1af74da687b62ffa1c9e4c20c56629574 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 20:22:51 -0400 Subject: [PATCH] fix(baseball-scoreboard): show the full slate in Vegas mode, not one game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_vegas_content() returned get_all_vegas_content_items(), the union of every scroll display's cached items. Those displays are populated by the standalone rotation as it renders each mode, so Vegas inherited whichever mode had rendered last. With one live game in progress that reduced the entire ticker entry to a single card; it also meant a game held by two displays could appear twice. The combined live/recent/upcoming set was only built when the union happened to come back empty, which is true just after a restart — so the plugin looked correct on boot and silently degraded. Vegas now reads a dedicated 'mixed' scroll display that the standalone modes cannot clobber, added as ScrollDisplayManager.get_vegas_content_items_for(). Measured on a 512x64 panel: 1 game before, 15 games after (5 live, 5 recent, 1 upcoming at the time of the first check). Two further fixes fall out of this: - Building the slate called update(), putting network I/O on the Vegas render path and freezing the ticker for seconds. The slate is now rendered from whatever data the plugin already holds, and is rebuilt only when the games actually change — fingerprinted on the game set plus score, inning, count and final flag. Refreshing data stays the update cycle's job. - Rendering went through prepare_and_display(), which also repoints the manager's active scroll display. Building the Vegas slate would therefore hijack an in-progress standalone scroll, so the next frame rendered the Vegas slate instead of the mode the rotation was showing. Added prepare_content(), which renders without switching the active display. Also corrects the game_card_width guidance, which advised lowering the value on multi-panel chains. That is backwards: on a wide panel cards need to be wider to stay readable. Now suggests roughly display width / 3. Verified with the safety harness at all sizes (all PASS; the extent-0% fill warnings on the larger panels are pre-existing and reproduce on origin/main), plus live on hardware with real MLB data. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- plugins.json | 2 +- plugins/baseball-scoreboard/CHANGELOG.md | 27 +++ .../baseball-scoreboard/config_schema.json | 6 +- plugins/baseball-scoreboard/manager.py | 208 ++++++++++++------ plugins/baseball-scoreboard/manifest.json | 8 +- plugins/baseball-scoreboard/scroll_display.py | 58 ++++- 6 files changed, 234 insertions(+), 75 deletions(-) diff --git a/plugins.json b/plugins.json index 41ef668a..76438a6c 100644 --- a/plugins.json +++ b/plugins.json @@ -76,7 +76,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.19.3" + "latest_version": "1.20.1" }, { "id": "basketball-scoreboard", diff --git a/plugins/baseball-scoreboard/CHANGELOG.md b/plugins/baseball-scoreboard/CHANGELOG.md index 99286bb8..ed9a1937 100644 --- a/plugins/baseball-scoreboard/CHANGELOG.md +++ b/plugins/baseball-scoreboard/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [1.20.1] - 2026-07-28 + +### Fixed +- **Vegas scroll showed only one game**: `get_vegas_content()` returned the union + of every scroll display's cached items, so once the standalone rotation had + rendered a mode, Vegas inherited that mode's games — with a single live game in + progress the whole ticker entry collapsed to one card. It now reads a dedicated + combined slate (live + recent + upcoming, across every enabled league) that + cannot be clobbered by the standalone displays, and a game held by two displays + is no longer shown twice. +- **Vegas content stalled the scroll**: building the Vegas slate called `update()`, + putting network I/O on the render path and freezing the ticker for seconds. The + slate is now rendered from whatever data the plugin already has, and is rebuilt + only when the games actually change (fingerprinted on scores, inning, count and + game set) instead of on every fetch. + +- **Vegas build hijacked the standalone scroll**: rendering the Vegas slate went + through `prepare_and_display()`, which also repoints the manager's active + scroll display, so the next standalone frame rendered the Vegas slate instead + of the game type the rotation was showing. Vegas now uses a new + `prepare_content()` that renders without switching the active display. + +### Changed +- **`game_card_width` guidance**: the description advised lowering it on + multi-panel chains, which is backwards — on a wide panel cards need to be + *wider* to stay readable. It now suggests roughly display width / 3. + ## [1.19.0] - 2026-07-09 ### Added diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index 1b6605b3..28277f17 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -161,7 +161,7 @@ "default": 128, "minimum": 32, "maximum": 512, - "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." + "description": "Width of each game card in scroll mode (pixels). Default 128, which suits a single 128px panel. On a wider chain raise it so each card stays readable - roughly display width divided by 3 shows about three games at once. Lower it to fit more games on screen at the cost of detail." } } }, @@ -602,7 +602,7 @@ "default": 128, "minimum": 32, "maximum": 512, - "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." + "description": "Width of each game card in scroll mode (pixels). Default 128, which suits a single 128px panel. On a wider chain raise it so each card stays readable - roughly display width divided by 3 shows about three games at once. Lower it to fit more games on screen at the cost of detail." } } }, @@ -1019,7 +1019,7 @@ "default": 128, "minimum": 32, "maximum": 512, - "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." + "description": "Width of each game card in scroll mode (pixels). Default 128, which suits a single 128px panel. On a wider chain raise it so each card stays readable - roughly display width divided by 3 shows about three games at once. Lower it to fit more games on screen at the cost of detail." } } }, diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 7af5cbad..2613fdf0 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -61,6 +61,12 @@ logger = logging.getLogger(__name__) +# Scroll-display key for the combined live/recent/upcoming slate that Vegas +# mode consumes. Kept separate from the per-mode displays ('live', 'recent', +# 'upcoming') that the standalone rotation renders, so the two cannot clobber +# each other's content. +VEGAS_SCROLL_KEY = 'mixed' + class BaseballScoreboardPlugin(BasePlugin if BasePlugin else object): """ @@ -189,6 +195,10 @@ def __init__( self._scroll_prepared: Dict[str, bool] = {} # {game_type: is_prepared} self._scroll_active_league: Dict[str, str] = {} # {game_type: league currently prepared} + # Fingerprint of the slate the Vegas cards were last rendered from, so + # they are only re-rendered when the games actually change. + self._vegas_signature: Optional[tuple] = None + # Enable high-FPS mode for scroll display (allows 100+ FPS scrolling) # This signals to the display controller to use high-FPS loop (8ms = 125 FPS) self.enable_scrolling = self._scroll_manager is not None @@ -310,6 +320,9 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None: self._scroll_active = {} self._scroll_prepared = {} self._scroll_active_league = {} + # The scroll manager was just rebuilt, so the rendered Vegas cards are + # gone; drop the fingerprint or they would never be rebuilt. + self._vegas_signature = None # Rebuild rotation modes and reset cycling state. self.modes = self._get_available_modes() @@ -3924,31 +3937,137 @@ def get_vegas_content(self) -> Optional[Any]: """ Get content for Vegas-style continuous scroll mode. - Triggers scroll content generation if cache is empty, then returns - the cached scroll image(s) for Vegas to compose into its scroll strip. + Returns one card per game across every enabled league and all three + game types (live, recent, upcoming), so the ticker shows the full + slate rather than a single game. + + This deliberately reads its own dedicated scroll display rather than + the union of all of them. Previously it returned + get_all_vegas_content_items() and only built the combined set when that + came back empty — which meant that once a standalone display mode had + rendered, Vegas inherited *that* mode's games. With one live game in + progress that reduced the whole ticker entry to a single card, and it + could also show a game twice when two displays held it. + + Content is rebuilt only when the underlying game data changes, so the + common case is a cheap cache read. Notably it does not call update(): + refreshing data is the update cycle's job, and doing network I/O here + would stall the Vegas render loop (measured at seconds) with the panel + frozen. Returns: - List of PIL Images from scroll displays, or None if no content + List of PIL Images, one per game, or None if there is nothing to show """ - if not hasattr(self, '_scroll_manager') or not self._scroll_manager: + if not getattr(self, '_scroll_manager', None): return None - images = self._scroll_manager.get_all_vegas_content_items() + try: + games, leagues = self._collect_games_for_scroll(live_priority_active=False) + except Exception: + self.logger.exception("[Baseball Vegas] Failed to collect games") + return None - if not images: - self.logger.info("[Baseball Vegas] Triggering scroll content generation") - self._ensure_scroll_content_for_vegas() - images = self._scroll_manager.get_all_vegas_content_items() + if not games: + self.logger.debug("[Baseball Vegas] No games available") + return None - if images: - total_width = sum(img.width for img in images) + signature = self._vegas_game_signature(games) + images = self._scroll_manager.get_vegas_content_items_for(VEGAS_SCROLL_KEY) + + if not images or signature != self._vegas_signature: + reason = "no cached content" if not images else "game data changed" self.logger.info( - "[Baseball Vegas] Returning %d image(s), %dpx total", - len(images), total_width + "[Baseball Vegas] Rebuilding scroll content (%s): %d game(s) from %s", + reason, len(games), ', '.join(leagues) or 'no leagues' ) - return images + if self._build_vegas_scroll_content(games, leagues): + self._vegas_signature = signature + images = self._scroll_manager.get_vegas_content_items_for(VEGAS_SCROLL_KEY) - return None + if not images: + return None + + total_width = sum(img.width for img in images) + self.logger.info( + "[Baseball Vegas] Returning %d image(s), %dpx total", + len(images), total_width + ) + return images + + def _vegas_game_signature(self, games: List[Dict]) -> tuple: + """ + Build a cheap fingerprint of the game slate. + + Rebuilding the Vegas cards means re-rendering every game, so it should + only happen when something a viewer would notice has actually changed — + the set of games, or a score/inning within them. Every field is read + with .get() because game dicts vary by league and completeness. + """ + fingerprint = [] + for game in games: + status = game.get('status') + state = status.get('state') if isinstance(status, dict) else status + fingerprint.append(( + game.get('id') or game.get('game_id') or game.get('start_time'), + game.get('league'), + state, + game.get('home_abbr'), game.get('away_abbr'), + game.get('home_score'), game.get('away_score'), + game.get('inning'), game.get('inning_half'), + game.get('outs'), game.get('balls'), game.get('strikes'), + game.get('is_final'), + )) + return tuple(fingerprint) + + def _build_vegas_scroll_content( + self, games: List[Dict], leagues: List[str] + ) -> bool: + """ + Render the combined game slate into the Vegas scroll display. + + Args: + games: Games collected across all leagues and game types + leagues: League IDs represented in ``games`` + + Returns: + True if content was rendered successfully + """ + rankings_cache = ( + self._get_rankings_cache() if hasattr(self, '_get_rankings_cache') else None + ) + + try: + # prepare_content, not prepare_and_display: the latter also makes + # this the manager's active scroll display, which would hijack the + # standalone rotation's in-progress scroll. + success = self._scroll_manager.prepare_content( + games, VEGAS_SCROLL_KEY, leagues, rankings_cache + ) + except Exception: + self.logger.exception("[Baseball Vegas] Error rendering scroll content") + return False + + if not success: + self.logger.warning("[Baseball Vegas] Failed to generate scroll content") + return False + + counts = {'live': 0, 'recent': 0, 'upcoming': 0} + for game in games: + status = game.get('status') + state = status.get('state') if isinstance(status, dict) else status + if state == 'in': + counts['live'] += 1 + elif state == 'post': + counts['recent'] += 1 + elif state == 'pre': + counts['upcoming'] += 1 + + summary = ', '.join(f"{n} {kind}" for kind, n in counts.items() if n) + self.logger.info( + "[Baseball Vegas] Generated scroll content: %d games (%s) from %s", + len(games), summary or 'unclassified', ', '.join(leagues) + ) + return True def get_vegas_content_type(self) -> str: """ @@ -3982,66 +4101,25 @@ def get_vegas_display_mode(self) -> 'VegasDisplayMode': def _ensure_scroll_content_for_vegas(self) -> None: """ - Ensure scroll content is generated for Vegas mode. + Build the combined Vegas slate if it is missing. - This method is called by get_vegas_content() when the scroll cache is empty. - It collects all game types (live, recent, upcoming) organized by league. + Retained for backward compatibility; get_vegas_content() now handles + rebuilding directly and keys off a data fingerprint. Unlike the previous + implementation this does not call update() — refreshing data is the + update cycle's job, and doing network I/O from the Vegas render path + stalled the scroll for seconds with the panel frozen. """ - if not hasattr(self, '_scroll_manager') or not self._scroll_manager: + if not getattr(self, '_scroll_manager', None): self.logger.debug("[Baseball Vegas] No scroll manager available") return - # Refresh internal managers/cache so Vegas has up-to-date content - try: - if hasattr(self, 'update') and callable(self.update): - self.update() - self.logger.debug("[Baseball Vegas] Refreshed managers via update()") - elif hasattr(self, 'refresh_managers') and callable(self.refresh_managers): - self.refresh_managers() - self.logger.debug("[Baseball Vegas] Refreshed managers via refresh_managers()") - elif hasattr(self, '_update') and callable(self._update): - self._update() - self.logger.debug("[Baseball Vegas] Refreshed managers via _update()") - except Exception as e: - self.logger.debug(f"[Baseball Vegas] Manager refresh failed (non-fatal): {e}") - - # Collect all games (live, recent, upcoming) organized by league games, leagues = self._collect_games_for_scroll(live_priority_active=False) - if not games: self.logger.debug("[Baseball Vegas] No games available") return - # Count games by type for logging - game_type_counts = {'live': 0, 'recent': 0, 'upcoming': 0} - for game in games: - state = game.get('status', {}).get('state', '') - if state == 'in': - game_type_counts['live'] += 1 - elif state == 'post': - game_type_counts['recent'] += 1 - elif state == 'pre': - game_type_counts['upcoming'] += 1 - - # Get rankings cache if available - rankings_cache = self._get_rankings_cache() if hasattr(self, '_get_rankings_cache') else None - - # Prepare scroll content with mixed game types - # Note: Using 'mixed' as game_type indicator for scroll config - success = self._scroll_manager.prepare_and_display( - games, 'mixed', leagues, rankings_cache - ) - - if success: - type_summary = ', '.join( - f"{count} {gtype}" for gtype, count in game_type_counts.items() if count > 0 - ) - self.logger.info( - f"[Baseball Vegas] Successfully generated scroll content: " - f"{len(games)} games ({type_summary}) from {', '.join(leagues)}" - ) - else: - self.logger.warning("[Baseball Vegas] Failed to generate scroll content") + if self._build_vegas_scroll_content(games, leagues): + self._vegas_signature = self._vegas_game_signature(games) def cleanup(self) -> None: """Clean up resources.""" diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index c69c6902..20cf0d3e 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "baseball-scoreboard", "name": "Baseball Scoreboard", - "version": "1.19.3", + "version": "1.20.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -30,6 +30,12 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "released": "2026-07-28", + "version": "1.20.1", + "notes": "Fix Vegas scroll mode showing only a single game: get_vegas_content() inherited whichever standalone display mode had rendered last, so one live game collapsed the whole ticker entry to one card. It now reads a dedicated combined live/recent/upcoming slate that the standalone modes cannot clobber, rebuilt only when the games actually change. Also stops the Vegas build doing network I/O on the render path (which froze the ticker) and stops it hijacking an in-progress standalone scroll.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-18", "version": "1.19.3", diff --git a/plugins/baseball-scoreboard/scroll_display.py b/plugins/baseball-scoreboard/scroll_display.py index 8ac6157f..042fa01e 100644 --- a/plugins/baseball-scoreboard/scroll_display.py +++ b/plugins/baseball-scoreboard/scroll_display.py @@ -593,17 +593,43 @@ def prepare_and_display( Returns: True if scroll was started successfully """ - scroll_display = self.get_scroll_display(game_type) - - success = scroll_display.prepare_scroll_content( - games, game_type, leagues, rankings_cache - ) + success = self.prepare_content(games, game_type, leagues, rankings_cache) if success: self._current_game_type = game_type return success + def prepare_content( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Dict[str, int] = None + ) -> bool: + """ + Render content for one scroll display without making it the active one. + + Vegas mode builds its own combined slate in the background while the + standalone rotation may be mid-scroll on a different game type. Going + through prepare_and_display() for that would repoint + ``_current_game_type``, so the next display_frame() would render the + Vegas slate instead of the mode the rotation is actually showing. + + Args: + games: List of game dictionaries + game_type: Scroll display key to render into + leagues: List of leagues + rankings_cache: Optional team rankings cache + + Returns: + True if content was prepared successfully + """ + scroll_display = self.get_scroll_display(game_type) + return scroll_display.prepare_scroll_content( + games, game_type, leagues, rankings_cache + ) + def display_frame(self, game_type: Optional[str] = None) -> bool: """ Display the next frame of the current scroll. @@ -681,3 +707,25 @@ def get_all_vegas_content_items(self) -> list: if vegas_items: items.extend(vegas_items) return items + + def get_vegas_content_items_for(self, game_type: str) -> list: + """ + Return the Vegas item list for a single scroll display. + + Vegas mode needs the items from one specific display (the combined + live/recent/upcoming set), not the union across all of them. + get_all_vegas_content_items() returns whatever the standalone display + modes happen to have rendered, which both under-reports (only the last + rendered mode's games) and can double-count a game that appears in two + displays. + + Args: + game_type: Scroll display key, e.g. 'mixed' + + Returns: + Copy of that display's Vegas items, or an empty list if absent. + """ + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return [] + return list(getattr(scroll_display, '_vegas_content_items', None) or [])