diff --git a/plugins.json b/plugins.json index 41ef668..76438a6 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 99286bb..ed9a193 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 1b6605b..28277f1 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 7af5cba..2613fdf 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 c69c690..20cf0d3 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 8ac6157..042fa01 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 [])