From 1de12b641ab8d939deb3e998dbbf146444be9e42 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 16:07:57 -0400 Subject: [PATCH 1/9] fix(hockey): draw date and time on upcoming scroll/Vegas cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upcoming hockey games scrolled past as two logos and a bare "VS" — no date, no time — which also left the two logos looking pressed together with nothing between them. The cause was a key mismatch, not missing data. ESPN supplies the start time (shortDetail is "9/19 - 7:00 PM EDT"), but the scroll/Vegas path feeds cards straight from the sports extractor, which emits flat game_date/game_time plus a start_time_utc datetime. The card instead read status.short_detail with a fallback to start_time — neither of which that payload contains — so both branches found empty strings and drew nothing. The peer sports (football, basketball) read the flat keys and render correctly, which is why hockey alone looked wrong. _upcoming_date_and_time() now resolves all three payload shapes: the flat extractor keys, the nested shape built by data_fetcher.py, and a raw start time parsed as a last resort. Time renders top-center and date bottom-center, matching the other sports' cards. The combined nested string is split and trimmed of its timezone suffix so it fits a card. Logo geometry is unchanged: football uses the identical logo_slot formula, so the crowding was a symptom of the blank card, not a separate layout bug. Verified against real ESPN data and the safety harness — 16/16 PASS across every supported panel size, no text overflow in any of them. --- plugins.json | 4 +- plugins/hockey-scoreboard/game_renderer.py | 109 ++++++++++++++++----- plugins/hockey-scoreboard/manifest.json | 8 +- 3 files changed, 92 insertions(+), 29 deletions(-) diff --git a/plugins.json b/plugins.json index 92caae7e..14eedf62 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-08-05", + "last_updated": "2026-08-06", "plugins": [ { "id": "cricket-scoreboard", @@ -335,7 +335,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.8.0", + "latest_version": "1.8.1", "icon": "fas fa-hockey-puck" }, { diff --git a/plugins/hockey-scoreboard/game_renderer.py b/plugins/hockey-scoreboard/game_renderer.py index a93d85b7..d888877c 100644 --- a/plugins/hockey-scoreboard/game_renderer.py +++ b/plugins/hockey-scoreboard/game_renderer.py @@ -7,8 +7,10 @@ import logging import os +from datetime import datetime, timezone from pathlib import Path from typing import Any, ClassVar, Dict, Optional, Tuple +from zoneinfo import ZoneInfo from PIL import Image, ImageDraw, ImageFont logger = logging.getLogger(__name__) @@ -606,36 +608,91 @@ def _draw_recent_game_status(self, draw: ImageDraw.Draw, _game: Dict) -> None: status_y = 1 self._draw_text_with_outline(draw, status_text, (status_x, status_y), self.fonts['time']) - def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: - """Draw status elements for an upcoming hockey game.""" - # Get game time from status - status = game.get('status', {}) - game_time = status.get('short_detail', '') + def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: + """Resolve (date, time) text for an upcoming game from any payload shape. - if game_time: - time_width = draw.textlength(game_time, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 - time_y = 1 - self._draw_text_with_outline(draw, game_time, (time_x, time_y), self.fonts['time']) - else: - # Fallback: try to parse start_time - start_time = game.get("start_time", "") - if start_time: - try: - from datetime import datetime - import pytz + The scroll/Vegas path feeds cards straight from the sports extractor, + which emits flat ``game_date``/``game_time`` (already localized) and a + ``start_time_utc`` datetime -- it has no ``status.short_detail`` and no + ``start_time``. Reading only the nested keys is what left these cards + showing a bare "VS": both lookups missed and each branch drew nothing. + Prefer the flat keys, then the nested payload built by data_fetcher.py, + then parse the raw start time as a last resort. + """ + date_text = str(game.get("game_date", "") or "") + time_text = str(game.get("game_time", "") or "") + if date_text or time_text: + return date_text, time_text + + # Nested shape (data_fetcher.py): "9/19 - 7:00 PM EDT" carries both + # halves in one string, which overflows the card if drawn as-is. + short_detail = str(game.get("status", {}).get("short_detail", "") or "") + if short_detail: + head, sep, tail = short_detail.partition(" - ") + date_part, time_part = (head, tail) if sep else ("", head) + return date_part.strip(), self._compact_time(time_part) + + raw_start = game.get("start_time_utc") or game.get("start_time") or "" + if not raw_start: + return "", "" + try: + if isinstance(raw_start, datetime): + start_dt = raw_start + else: + start_dt = datetime.fromisoformat(str(raw_start).replace("Z", "+00:00")) + local_dt = start_dt.astimezone(self._display_tzinfo()) + return local_dt.strftime("%m/%d").lstrip("0"), local_dt.strftime("%I:%M%p").lstrip("0") + except (ValueError, TypeError) as e: + self.logger.debug(f"Failed to parse start time '{raw_start}': {e}") + return "", "" + + @staticmethod + def _compact_time(text: str) -> str: + """Trim "7:00 PM EDT" to "7:00PM" so it fits a 64px-wide half-card.""" + tokens = text.split() + if not tokens: + return "" + # Drop a trailing timezone abbreviation ("EDT"), keeping the meridiem. + if len(tokens) > 1 and tokens[-1].upper() not in {"AM", "PM"}: + tokens = tokens[:-1] + if len(tokens) >= 2 and tokens[-1].upper() in {"AM", "PM"}: + return "".join(tokens[-2:]) + return tokens[-1] + + def _display_tzinfo(self): + """Timezone for rendering raw start times; falls back to UTC.""" + try: + configured = (self.config or {}).get("timezone") + if configured: + return ZoneInfo(configured) + except Exception: + pass + return timezone.utc - dt = datetime.fromisoformat(start_time.replace('Z', '+00:00')) - local_dt = dt.astimezone(pytz.utc) # Use UTC for now + def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: + """Draw date/time for an upcoming hockey game. + + Matches the other sports' scroll cards: time top-center, date + bottom-center, so the two logos are never left touching with a bare + "VS" between them. + """ + date_text, time_text = self._upcoming_date_and_time(game) - game_date = local_dt.strftime("%b %d") + if time_text: + time_width = draw.textlength(time_text, font=self.fonts['time']) + time_x = (self.display_width - time_width) // 2 + self._draw_text_with_outline( + draw, time_text, (time_x, 1), self.fonts['time'] + ) - date_width = draw.textlength(game_date, font=self.fonts['time']) - date_x = (self.display_width - date_width) // 2 - date_y = 1 - self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['time']) - except (ValueError, TypeError) as e: - self.logger.debug(f"Failed to parse start_time '{start_time}': {e}") + if date_text: + date_font = self.fonts.get('detail') or self.fonts['time'] + date_width = draw.textlength(date_text, font=date_font) + date_x = (self.display_width - date_width) // 2 + date_y = self.display_height - 7 + self._draw_text_with_outline( + draw, date_text, (date_x, date_y), date_font + ) def _draw_records_or_rankings(self, draw: ImageDraw.Draw, game: Dict) -> None: """Draw team records or rankings.""" diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index a91f069d..11bfc894 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.8.0", + "version": "1.8.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -54,6 +54,12 @@ } ], "versions": [ + { + "version": "1.8.1", + "released": "2026-08-06", + "notes": "Fix upcoming games showing a bare \"VS\" with no date or time on scroll and Vegas cards. The card read the start time from status.short_detail and start_time, but the scroll path is fed by the sports extractor, which emits game_date, game_time and start_time_utc instead -- so both lookups came up empty and nothing was drawn. Date and time now render top- and bottom-center, matching the other sports' scroll cards, and all three payload shapes are handled.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.8.0", "released": "2026-08-05", From 69a203fb18d54616702986f75b88057270cbd56c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 16:21:56 -0400 Subject: [PATCH 2/9] fix(hockey): restore the live game clock on scroll/Vegas cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following the dead expression at hockey.py:79. The expression itself was harmless — _extract_game_details_common already stores that value as details["status_text"] (sports.py:908), so it was a refactor leftover, sibling to the commented-out line below it. Removed. Looking at why it was there surfaced a real bug on the same path. Live scroll/Vegas cards render "P2" instead of "P2 12:34": the clock is silently dropped. _normalize_game_payload writes the flat clock into status["clock"], but _draw_live_game_status reads status["display_clock"] — the canonical key that data_fetcher.py builds. Nothing reads status["clock"] at all, so the value was written and never used. The full-screen scorebug is unaffected because it reads the flat game["clock"] directly (hockey.py:223), which is why this only shows up in scroll/Vegas. The normalizer now emits the canonical nested shape: display_clock alongside clock, short_detail from status_text, and a state derived from the extractor's is_live/is_final/is_upcoming flags when absent. That last one makes render_game_card correct standalone — _collect_games_for_scroll injects state from the mode today, so a caller that skips it would otherwise land in the wrong branch and draw nothing. Verified live "P2 12:34", recent "Final", and upcoming date/time all render, including with no injected state. Safety harness 16/16 PASS; the widest live string measures 64px, fitting the narrowest panel exactly. --- plugins/hockey-scoreboard/game_renderer.py | 27 ++++++++++++++++++++-- plugins/hockey-scoreboard/hockey.py | 1 - plugins/hockey-scoreboard/manifest.json | 2 +- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/plugins/hockey-scoreboard/game_renderer.py b/plugins/hockey-scoreboard/game_renderer.py index d888877c..62d3ec69 100644 --- a/plugins/hockey-scoreboard/game_renderer.py +++ b/plugins/hockey-scoreboard/game_renderer.py @@ -308,12 +308,35 @@ def _normalize_game_payload(self, game: Dict[str, Any]) -> Dict[str, Any]: status = {} if 'status_text' in normalized and not status.get('detail'): status['detail'] = normalized.get('status_text', '') + # The extractor's status_text ("P2 12:34", "Final", "7:30 PM") is the + # same value data_fetcher.py stores as short_detail. + if 'status_text' in normalized and not status.get('short_detail'): + status['short_detail'] = normalized.get('status_text', '') if 'period' in normalized and not status.get('period'): status['period'] = normalized.get('period', '') - if 'clock' in normalized and not status.get('clock'): - status['clock'] = normalized.get('clock', '') + # display_clock is the canonical nested key (data_fetcher.py builds it, + # _draw_live_game_status reads it). Writing only 'clock' here left live + # scroll/Vegas cards rendering "P2" with the game clock silently + # dropped; 'clock' is kept alongside it for any external consumer. + if 'clock' in normalized: + if not status.get('clock'): + status['clock'] = normalized.get('clock', '') + if not status.get('display_clock'): + status['display_clock'] = normalized.get('clock', '') + if 'display_clock' in normalized and not status.get('display_clock'): + status['display_clock'] = normalized.get('display_clock', '') if 'state' in normalized and not status.get('state'): status['state'] = normalized.get('state', '') + # Fall back to the extractor's booleans so a card rendered outside + # _collect_games_for_scroll (which injects state from the mode) still + # picks the right live/final branch instead of drawing nothing. + if not status.get('state'): + if normalized.get('is_live'): + status['state'] = 'in' + elif normalized.get('is_final'): + status['state'] = 'post' + elif normalized.get('is_upcoming'): + status['state'] = 'pre' normalized['status'] = status return normalized diff --git a/plugins/hockey-scoreboard/hockey.py b/plugins/hockey-scoreboard/hockey.py index b570c478..d91ee596 100644 --- a/plugins/hockey-scoreboard/hockey.py +++ b/plugins/hockey-scoreboard/hockey.py @@ -76,7 +76,6 @@ def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: away_shots = round(home_team_saves / home_team_saves_per) if away_team_saves_per > 0: home_shots = round(away_team_saves / away_team_saves_per) - status["type"].get("shortDetail", "") if situation and status["type"]["state"] == "in": # Detect scoring events from status detail diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 11bfc894..e243a87c 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -57,7 +57,7 @@ { "version": "1.8.1", "released": "2026-08-06", - "notes": "Fix upcoming games showing a bare \"VS\" with no date or time on scroll and Vegas cards. The card read the start time from status.short_detail and start_time, but the scroll path is fed by the sports extractor, which emits game_date, game_time and start_time_utc instead -- so both lookups came up empty and nothing was drawn. Date and time now render top- and bottom-center, matching the other sports' scroll cards, and all three payload shapes are handled.", + "notes": "Fix missing text on scroll and Vegas game cards. Upcoming games showed a bare \"VS\" with no date or time: the card read status.short_detail and start_time, but the scroll path is fed by the sports extractor, which emits game_date, game_time and start_time_utc instead, so both lookups came up empty. Live games showed the period without the game clock (\"P2\" rather than \"P2 12:34\") because the payload normalizer wrote status.clock while the card reads the canonical status.display_clock. Date and time now render top- and bottom-center to match the other sports, the live clock renders again, and the normalizer fills in short_detail, display_clock and a state derived from the extractor's is_live/is_final/is_upcoming flags.", "ledmatrix_min_version": "2.0.0" }, { From 8c6c01f775af2316c73ad3bdbaaa3d9a765a9044 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 16:42:18 -0400 Subject: [PATCH 3/9] feat(hockey): keep the score off the logos and space out scroll cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two layout complaints from the Vegas strip, both confirmed in renders. Score drawn over the logos. Logos were thumbnailed into a display_height box while logo_slot was also min(display_height, width // 2), so on a 128x64 card two 64px logos exactly filled it and met in the middle — precisely where the score and "VS" are centred. The card now reserves a centre gap before sizing the logos, and the slot is still capped at display_height, so the sizes that already had a wide middle (128x32, 256x32) come out byte-identical; only 128x64-style cards shrink. On a 128px card the gap is 36px against a 30px "1-2". Override with customization.center_gap; 0 restores the old edge-to-edge look. The logo cache key now includes the box, since one cache is shared by renderers built for different card sizes. Cards too close together. gap_between_games never reached Vegas, which stitches _vegas_content_items itself and never sees the scroll helper's item_gap — so Vegas always ran at the fixed 12px-per-side padding regardless of the setting. That padding also carried a stale comment about logos at -10/display_width+10, a layout this renderer no longer uses. Spacing is now baked into each card as half the gap per side and item_gap drops to 0, so both paths separate cards by exactly gap_between_games. The code default moves 24 -> 48, matching what config_schema.json has advertised all along. Safety harness 16/16 PASS. Centre text clears the logos at every supported size except "1-2" on 64x32, where a 64px-wide card cannot hold two logos and a 30px score at once; "VS" still fits there. --- plugins.json | 2 +- plugins/hockey-scoreboard/config_schema.json | 8 +++ plugins/hockey-scoreboard/game_renderer.py | 68 +++++++++++++++----- plugins/hockey-scoreboard/manifest.json | 6 +- plugins/hockey-scoreboard/scroll_display.py | 20 ++++-- 5 files changed, 79 insertions(+), 25 deletions(-) diff --git a/plugins.json b/plugins.json index 14eedf62..c94dd5b4 100644 --- a/plugins.json +++ b/plugins.json @@ -335,7 +335,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.8.1", + "latest_version": "1.9.0", "icon": "fas fa-hockey-puck" }, { diff --git a/plugins/hockey-scoreboard/config_schema.json b/plugins/hockey-scoreboard/config_schema.json index 4b2110f9..352b2448 100644 --- a/plugins/hockey-scoreboard/config_schema.json +++ b/plugins/hockey-scoreboard/config_schema.json @@ -1297,6 +1297,14 @@ "title": "Display Customization", "description": "Customize fonts for different text elements on the scoreboard", "properties": { + "center_gap": { + "x-advanced": true, + "type": "integer", + "title": "Center Gap", + "description": "Pixels kept clear down the middle of a game card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", + "minimum": 0, + "maximum": 64 + }, "score_text": { "type": "object", "title": "Game Score", diff --git a/plugins/hockey-scoreboard/game_renderer.py b/plugins/hockey-scoreboard/game_renderer.py index 62d3ec69..4d1c6d4c 100644 --- a/plugins/hockey-scoreboard/game_renderer.py +++ b/plugins/hockey-scoreboard/game_renderer.py @@ -186,17 +186,21 @@ def _load_and_resize_logo( self, team_abbrev: str, logo_path: Optional[Path] = None, - league: str = 'nhl' + league: str = 'nhl', + max_width: Optional[int] = None ) -> Optional[Image.Image]: - """Load and resize a team logo with caching.""" - cache_key = f"{league}_{team_abbrev}" + """Load and resize a team logo with caching. + + max_width bounds the logo horizontally so it stays inside its slot and + clear of the center gap; it is part of the cache key because the same + cache dict is shared by renderers built for different card sizes. + """ + box_w = int(max_width) if max_width else self.display_height + box_h = self.display_height + cache_key = f"{league}_{team_abbrev}_{box_w}x{box_h}" if cache_key in self._logo_cache: return self._logo_cache[cache_key] - # Also check without league prefix for backward compatibility - if team_abbrev in self._logo_cache: - return self._logo_cache[team_abbrev] - try: # Use provided path or get from league config if logo_path is None or not os.path.exists(logo_path): @@ -211,13 +215,12 @@ def _load_and_resize_logo( else: logo = logo_file.copy() - # Crop transparent padding then scale so ink fills display_height. - # thumbnail into a display_height square box preserves aspect ratio - # and prevents wide logos from exceeding their half-card slot. + # Crop transparent padding, then thumbnail into the slot box so + # the logo keeps its aspect ratio and never spills past its slot. bbox = logo.getbbox() if bbox: logo = logo.crop(bbox) - logo.thumbnail((self.display_height, self.display_height), RESAMPLE_FILTER) + logo.thumbnail((box_w, box_h), RESAMPLE_FILTER) self._logo_cache[cache_key] = logo return logo @@ -512,16 +515,22 @@ def render_game_card( home_abbr = home_team.get('abbrev', '') away_abbr = away_team.get('abbrev', '') + # Reserve a strip down the middle for the score/"VS" before sizing the + # logos, so the two never share pixels. + logo_slot = self._logo_slot_width() + # Load logos home_logo = self._load_and_resize_logo( home_abbr, logo_dir / f"{home_abbr}.png", - league + league, + max_width=logo_slot ) away_logo = self._load_and_resize_logo( away_abbr, logo_dir / f"{away_abbr}.png", - league + league, + max_width=logo_slot ) if not home_logo or not away_logo: @@ -529,9 +538,7 @@ def render_game_card( center_y = self.display_height // 2 - # Draw logos — each centered within a slot on its side; cap at half the card - # width so home_slot_start stays non-negative on square/tall displays - logo_slot = min(self.display_height, self.display_width // 2) + # Draw logos — each centered within its slot on its side. away_x = (logo_slot - away_logo.width) // 2 away_y = center_y - (away_logo.height // 2) main_img.paste(away_logo, (away_x, away_y), away_logo) @@ -669,6 +676,35 @@ def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: self.logger.debug(f"Failed to parse start time '{raw_start}': {e}") return "", "" + # Middle strip reserved for the score / "VS", as a fraction of card width. + # 0.28 clears "1-2" (30px) on a 128px card with room to spare. + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + # 22 so "VS" (20px) still clears the logos on the narrowest 64px card. + CENTER_GAP_MIN_PX: ClassVar[int] = 22 + CENTER_GAP_MAX_PX: ClassVar[int] = 40 + + def _center_gap_width(self) -> int: + """Width of the middle strip kept clear of logos. + + ``customization.center_gap`` overrides it; 0 restores the old + edge-to-edge logos. + """ + configured = (self.config or {}).get('customization', {}).get('center_gap') + if isinstance(configured, (int, float)) and configured >= 0: + return int(configured) + scaled = round(self.display_width * self.CENTER_GAP_RATIO) + return int(max(self.CENTER_GAP_MIN_PX, min(self.CENTER_GAP_MAX_PX, scaled))) + + def _logo_slot_width(self) -> int: + """Per-side logo slot, leaving the center gap clear. + + Still capped at display_height, so wide/short cards (128x32, 256x32) + already have a large center gap and are left exactly as they were -- + only the sizes where the logos met in the middle (128x64, 64x32) shrink. + """ + available = (self.display_width - self._center_gap_width()) // 2 + return max(8, min(self.display_height, available)) + @staticmethod def _compact_time(text: str) -> str: """Trim "7:00 PM EDT" to "7:00PM" so it fits a 64px-wide half-card.""" diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index e243a87c..80f44f5e 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.8.1", + "version": "1.9.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -55,9 +55,9 @@ ], "versions": [ { - "version": "1.8.1", + "version": "1.9.0", "released": "2026-08-06", - "notes": "Fix missing text on scroll and Vegas game cards. Upcoming games showed a bare \"VS\" with no date or time: the card read status.short_detail and start_time, but the scroll path is fed by the sports extractor, which emits game_date, game_time and start_time_utc instead, so both lookups came up empty. Live games showed the period without the game clock (\"P2\" rather than \"P2 12:34\") because the payload normalizer wrote status.clock while the card reads the canonical status.display_clock. Date and time now render top- and bottom-center to match the other sports, the live clock renders again, and the normalizer fills in short_detail, display_clock and a state derived from the extractor's is_live/is_final/is_upcoming flags.", + "notes": "Fix missing text and cramped spacing on scroll and Vegas game cards. Upcoming games showed a bare \"VS\" with no date or time: the card read status.short_detail and start_time, but the scroll path is fed by the sports extractor, which emits game_date, game_time and start_time_utc instead, so both lookups came up empty. Live games showed the period without the game clock (\"P2\" rather than \"P2 12:34\") because the payload normalizer wrote status.clock while the card reads the canonical status.display_clock. Date and time now render top- and bottom-center to match the other sports, and the live clock renders again. The card also reserves a gap down the middle so the score or VS is no longer drawn on top of the team logos -- tune it with customization.center_gap, or set 0 for the previous edge-to-edge logos. Cards that already had a wide centre (128x32, 256x32) are unchanged. Finally, gap_between_games is now honoured in Vegas mode: the spacing is baked into each card rather than applied only by the scroll helper, and the code default moves from 24 to 48 to match the value the config schema has always advertised.", "ledmatrix_min_version": "2.0.0" }, { diff --git a/plugins/hockey-scoreboard/scroll_display.py b/plugins/hockey-scoreboard/scroll_display.py index 5a16a58b..e6c3b92e 100644 --- a/plugins/hockey-scoreboard/scroll_display.py +++ b/plugins/hockey-scoreboard/scroll_display.py @@ -205,7 +205,9 @@ def prepare_scroll_content( # Get scroll settings using primary league from the provided leagues list primary_league = leagues[0] if leagues else None scroll_settings = self._get_scroll_settings(primary_league) - gap_between_games = scroll_settings.get("gap_between_games", 24) + # 48 matches the legacy scroll path's default and gives the cards + # visible separation; 24 read as one continuous run of logos. + gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) game_card_width = scroll_settings.get("game_card_width", 128) @@ -266,9 +268,15 @@ def prepare_scroll_content( individual_game_type = game_type game_img = renderer.render_game_card(game, individual_game_type) - # Add horizontal padding to prevent logos from being cut off at edges - # Logos are positioned at -10 and display_width+10, so we need padding - padding = 12 # Padding on each side to ensure logos aren't cut off + # Half the gap on each side, so adjacent cards are separated + # by exactly gap_between_games. Baking it into the card + # matters for Vegas, which stitches _vegas_content_items + # itself and never sees the scroll helper's item_gap -- that + # is why Vegas used to run cards close together regardless + # of the setting. (The old fixed 12px padding here was + # compensating for logos drawn at -10/display_width+10, a + # layout this renderer no longer uses.) + padding = max(4, gap_between_games // 2) padded_width = game_img.width + (padding * 2) padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) padded_img.paste(game_img, (padding, 0)) @@ -290,7 +298,9 @@ def prepare_scroll_content( # Create scrolling image using ScrollHelper self.scroll_helper.create_scrolling_image( content_items, - item_gap=gap_between_games, + # Spacing already baked into each card above, so both this path + # and Vegas separate cards by the same gap_between_games. + item_gap=0, element_gap=0 # No element gap - each item is a complete game card ) From 040323963e38c659d00a0c4e48a7bb90b225e6f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 17:18:05 -0400 Subject: [PATCH 4/9] feat(sports): unify upcoming scroll/Vegas cards across all eight scoreboards Applies the hockey card layout to every sports plugin, and makes the pieces configurable. Scoped deliberately to game_renderer.py and scroll_display*.py, which are reached only from scroll and Vegas modes -- the full-screen scoreboard is a separate code path and is untouched. No 0-0 before a game starts. basketball, football, afl, nrl and soccer drew the score unconditionally, so an upcoming game rendered the extractor's placeholder 0-0 between the logos. The score is now gated to live/recent, and upcoming cards get "VS" instead. hockey and lacrosse already gated it. The score no longer sits on the logos. Logos were sized into a display_height box while the slot was min(display_height, width // 2), so on a 128x64 card two 64px logos filled it and met exactly where the score is centred. A centre gap is reserved before the logos are sized, and the slot stays capped at display_height so the wide/short cards (128x32, 256x32) are unchanged. Logo cache keys now carry the slot size, since one cache is shared by renderers built for different card widths. Dates read "Sep 19". The position is measured from the text rather than a fixed display_height - 7, which was only ever right for a 6px font -- soccer and nrl use a 10px detail font, where "Sep 19" ran 5px past the bottom of the card. The descender that exposed this does not exist in the old "9/19". gap_between_games now applies in Vegas. Vegas stitches its own content items and never sees the scroll helper's item_gap, so it always ran at the fixed 12px-per-side padding whatever the setting said. Spacing is baked into each card as half the gap per side with item_gap dropped to 0, so both paths separate cards by exactly gap_between_games. The code default moves 24 -> 48, which is what config_schema.json has advertised all along. New scroll_card config block in all eight schemas: upcoming_center (vs | date_time), date_format (abbrev | numeric) and center_gap in pixels (0 restores edge-to-edge logos). Safety harness: 168/168 PASS across all eight plugins and every supported panel size. Verified upcoming renders for all eight show VS, the time and "Sep 19" with no 0-0, and the date seated at 63/64px with no clipping. --- plugins.json | 14 +- plugins/afl-scoreboard/config_schema.json | 35 +++ plugins/afl-scoreboard/game_renderer.py | 179 ++++++++++++--- plugins/afl-scoreboard/manifest.json | 8 +- plugins/afl-scoreboard/scroll_display.py | 9 +- .../afl-scoreboard/scroll_display_legacy.py | 9 +- .../baseball-scoreboard/config_schema.json | 35 +++ plugins/baseball-scoreboard/game_renderer.py | 149 +++++++++++-- plugins/baseball-scoreboard/manifest.json | 8 +- plugins/baseball-scoreboard/scroll_display.py | 9 +- .../scroll_display_legacy.py | 9 +- .../basketball-scoreboard/config_schema.json | 35 +++ .../basketball-scoreboard/game_renderer.py | 204 ++++++++++++++---- plugins/basketball-scoreboard/manifest.json | 8 +- .../basketball-scoreboard/scroll_display.py | 9 +- .../scroll_display_legacy.py | 9 +- .../football-scoreboard/config_schema.json | 35 +++ plugins/football-scoreboard/game_renderer.py | 195 ++++++++++++++--- plugins/football-scoreboard/manifest.json | 8 +- plugins/football-scoreboard/scroll_display.py | 9 +- .../scroll_display_legacy.py | 9 +- plugins/hockey-scoreboard/config_schema.json | 43 +++- plugins/hockey-scoreboard/game_renderer.py | 103 +++++++-- plugins/hockey-scoreboard/manifest.json | 2 +- .../scroll_display_legacy.py | 9 +- .../lacrosse-scoreboard/config_schema.json | 35 +++ plugins/lacrosse-scoreboard/game_renderer.py | 162 +++++++++++--- plugins/lacrosse-scoreboard/manifest.json | 8 +- plugins/lacrosse-scoreboard/scroll_display.py | 9 +- .../scroll_display_legacy.py | 9 +- plugins/nrl-scoreboard/config_schema.json | 35 +++ plugins/nrl-scoreboard/game_renderer.py | 179 ++++++++++++--- plugins/nrl-scoreboard/manifest.json | 8 +- plugins/nrl-scoreboard/scroll_display.py | 9 +- .../nrl-scoreboard/scroll_display_legacy.py | 9 +- plugins/soccer-scoreboard/config_schema.json | 35 +++ plugins/soccer-scoreboard/game_renderer.py | 179 ++++++++++++--- plugins/soccer-scoreboard/manifest.json | 8 +- plugins/soccer-scoreboard/scroll_display.py | 9 +- .../scroll_display_legacy.py | 9 +- 40 files changed, 1549 insertions(+), 296 deletions(-) diff --git a/plugins.json b/plugins.json index c94dd5b4..ece27ae6 100644 --- a/plugins.json +++ b/plugins.json @@ -76,7 +76,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.23.0" + "latest_version": "1.24.0" }, { "id": "basketball-scoreboard", @@ -101,7 +101,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.11.0" + "latest_version": "1.12.0" }, { "id": "calendar", @@ -240,7 +240,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.12.0" + "latest_version": "2.13.0" }, { "id": "geochron", @@ -359,7 +359,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.8.0", + "latest_version": "1.9.0", "icon": "fas fa-baseball-ball" }, { @@ -760,7 +760,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.7.0" + "latest_version": "2.8.0" }, { "id": "static-image", @@ -1048,7 +1048,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.4.0", + "latest_version": "1.5.0", "last_updated": "2026-08-05" }, { @@ -1095,7 +1095,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.4.0" + "latest_version": "1.5.0" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/config_schema.json b/plugins/afl-scoreboard/config_schema.json index a053e8cd..8870b1ba 100644 --- a/plugins/afl-scoreboard/config_schema.json +++ b/plugins/afl-scoreboard/config_schema.json @@ -4,6 +4,41 @@ "description": "Configuration schema for the AFL (Australian Football League) Scoreboard plugin", "type": "object", "properties": { + "scroll_card": { + "type": "object", + "title": "Scroll & Vegas Card Layout", + "description": "Layout of the game cards built for scroll and Vegas modes. The full-screen scoreboard is drawn separately and is not affected by these settings.", + "x-advanced": true, + "properties": { + "upcoming_center": { + "type": "string", + "title": "Middle of an Upcoming Card", + "description": "What to show between the two logos before a game starts: VS, or the date and time stacked in the middle. Upcoming games never show a score, since the game has not been played.", + "enum": [ + "vs", + "date_time" + ], + "default": "vs" + }, + "date_format": { + "type": "string", + "title": "Date Format", + "description": "How to write the date on an upcoming card: abbrev shows \"Sep 19\", numeric shows \"9/19\".", + "enum": [ + "abbrev", + "numeric" + ], + "default": "abbrev" + }, + "center_gap": { + "type": "integer", + "title": "Center Gap", + "description": "Pixels kept clear down the middle of a card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", + "minimum": 0, + "maximum": 64 + } + } + }, "enabled": { "type": "boolean", "default": true, diff --git a/plugins/afl-scoreboard/game_renderer.py b/plugins/afl-scoreboard/game_renderer.py index a7c2ca6a..f1534cc2 100644 --- a/plugins/afl-scoreboard/game_renderer.py +++ b/plugins/afl-scoreboard/game_renderer.py @@ -172,7 +172,7 @@ def preload_logos(self, games: list, logo_dir: Path) -> None: game.get(f'{team_key.replace("abbr", "logo_url")}') ) if logo: - self._logo_cache[abbr] = logo + self._logo_cache[self._logo_cache_key(abbr)] = logo self.logger.debug(f"Preloaded {len(self._logo_cache)} team logos") @@ -185,7 +185,7 @@ def _load_and_resize_logo( ) -> Optional[Image.Image]: """Load and resize a team logo with caching.""" if team_abbrev in self._logo_cache: - return self._logo_cache[team_abbrev] + return self._logo_cache[self._logo_cache_key(team_abbrev)] try: # If the local copy is missing, try downloading it before giving @@ -211,9 +211,9 @@ def _load_and_resize_logo( bbox = logo.getbbox() if bbox: logo = logo.crop(bbox) - logo.thumbnail((self.display_height, self.display_height), Image.Resampling.LANCZOS) + logo.thumbnail((self._logo_slot_width(), self.display_height), Image.Resampling.LANCZOS) - self._logo_cache[team_abbrev] = logo + self._logo_cache[self._logo_cache_key(team_abbrev)] = logo return logo else: self.logger.error(f"Logo file still doesn't exist at {logo_path} after download attempt") @@ -495,7 +495,7 @@ def render_game_card( # Place logos — each centered within a slot on its side; cap at half the card # width so home_slot_start stays non-negative on square/tall displays - logo_slot = min(self.display_height, self.display_width // 2) + logo_slot = self._logo_slot_width() away_x = (logo_slot - away_logo.width) // 2 away_y = center_y - (away_logo.height // 2) @@ -507,13 +507,17 @@ def render_game_card( main_img.paste(home_logo, (home_x, home_y), home_logo) main_img.paste(away_logo, (away_x, away_y), away_logo) - # Draw scores (centered) - score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 - self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts['score'], - fill=self._score_color_for(game, game_type) - ) + # Draw scores (centered) — only once a game has started. Upcoming games + # have no score, so the extractor's 0-0 was pure noise. + if game_type in ("live", "recent"): + score_x = (self.display_width - score_width) // 2 + score_y = (self.display_height // 2) - 3 + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts['score'], + fill=self._score_color_for(game, game_type) + ) + elif game_type == "upcoming": + self._draw_upcoming_center(draw_overlay, game) # Draw period/status based on game type if game_type == "live": @@ -581,24 +585,145 @@ def _draw_recent_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: date_y = self.display_height - 7 self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['detail']) + # ------------------------------------------------------------------ + # Scroll/Vegas card options -- config["scroll_card"]. + # + # These only affect the cards this renderer builds, which are used by + # scroll_display.py and scroll_display_legacy.py alone. The full-screen + # scorebug is drawn elsewhere and is deliberately left untouched. + # ------------------------------------------------------------------ + # Middle strip kept clear of logos so the score / "VS" is never drawn on + # top of them. 0.28 of a 128px card clears "1-2" (30px) with room spare. + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + # 22 so "VS" (20px) still clears the logos on the narrowest 64px card. + CENTER_GAP_MIN_PX: ClassVar[int] = 22 + CENTER_GAP_MAX_PX: ClassVar[int] = 40 + _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ) + + def _logo_cache_key(self, name: str) -> str: + """Cache key scoped to the logo slot. + + One cache dict is shared by renderers built for different card widths, + so a logo sized for a wide slot must not be handed to a narrow one. + """ + return f"{name}@{self._logo_slot_width()}x{self.display_height}" + + def _scroll_card_option(self, key: str, default: Any = None) -> Any: + """Read one key from the scroll_card config block.""" + block = (self.config or {}).get("scroll_card") + if isinstance(block, dict) and block.get(key) is not None: + return block.get(key) + return default + + def _center_gap_width(self) -> int: + """Width of the middle strip kept clear of logos. + + ``scroll_card.center_gap`` overrides it; 0 restores edge-to-edge logos. + """ + configured = self._scroll_card_option("center_gap") + if isinstance(configured, (int, float)) and configured >= 0: + return int(configured) + scaled = round(self.display_width * self.CENTER_GAP_RATIO) + return int(max(self.CENTER_GAP_MIN_PX, min(self.CENTER_GAP_MAX_PX, scaled))) + + def _logo_slot_width(self) -> int: + """Per-side logo slot, leaving the center gap clear. + + Capped at display_height, so wide/short cards (128x32, 256x32) already + have a large middle and come out unchanged -- only the sizes where the + logos used to meet (128x64, 64x32) shrink. + """ + available = (self.display_width - self._center_gap_width()) // 2 + return max(8, min(self.display_height, available)) + + def _upcoming_center_mode(self) -> str: + """What sits in the middle of an upcoming card: 'vs' or 'date_time'.""" + mode = str(self._scroll_card_option("upcoming_center", "vs") or "vs").lower() + return mode if mode in ("vs", "date_time") else "vs" + + def _format_game_date(self, date_text: str) -> str: + """Render a date as "Sep 19" (default) or "9/19" (``date_format``).""" + raw = str(date_text or "").strip() + if not raw or self._scroll_card_option("date_format", "abbrev") == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + if len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): + month = int(parts[0]) + if 1 <= month <= 12: + return f"{self._MONTH_ABBR[month - 1]} {int(parts[1])}" + return raw + + def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: + """Draw the middle of an upcoming card. + + Never a score: an upcoming game has not started, so the extractor's + 0-0 is noise. Either "VS" (default) or the date and time stacked. + """ + if self._upcoming_center_mode() == "vs": + vs_text = "VS" + vs_width = draw.textlength(vs_text, font=self.fonts['score']) + vs_x = (self.display_width - vs_width) // 2 + vs_y = (self.display_height // 2) - 3 + self._draw_text_with_outline( + draw, vs_text, (vs_x, vs_y), self.fonts['score'] + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + date_text = self._format_game_date(date_text) + font = self.fonts.get('detail') or self.fonts['time'] + lines = [t for t in (date_text, time_text) if t] + if not lines: + return + line_h = 7 + top = (self.display_height // 2) - (len(lines) * line_h) // 2 + for i, line in enumerate(lines): + width = draw.textlength(line, font=font) + self._draw_text_with_outline( + draw, line, ((self.display_width - width) // 2, top + i * line_h), font + ) + + def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: + """(date, time) for an upcoming card, from the extractor's flat keys.""" + return ( + str(game.get("game_date", "") or ""), + str(game.get("game_time", "") or ""), + ) + def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: - """Draw status elements for an upcoming afl game.""" - # Game time (Top center) - game_time = game.get("game_time", "") - if game_time: - time_width = draw.textlength(game_time, font=self.fonts['time']) + """Draw date/time around an upcoming card: time top, date bottom. + + Skipped when the date and time are stacked in the middle instead -- + drawing both would print them twice. + """ + if self._upcoming_center_mode() != "vs": + return + + date_text, time_text = self._upcoming_date_and_time(game) + date_text = self._format_game_date(date_text) + + if time_text: + time_width = draw.textlength(time_text, font=self.fonts['time']) time_x = (self.display_width - time_width) // 2 - time_y = 1 - self._draw_text_with_outline(draw, game_time, (time_x, time_y), self.fonts['time']) - - # Game date (Bottom center) - game_date = game.get("game_date", "") - if game_date: - date_width = draw.textlength(game_date, font=self.fonts['detail']) + self._draw_text_with_outline( + draw, time_text, (time_x, 1), self.fonts['time'] + ) + + if date_text: + date_font = self.fonts.get('detail') or self.fonts['time'] + date_width = draw.textlength(date_text, font=date_font) date_x = (self.display_width - date_width) // 2 - date_y = self.display_height - 7 - self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['detail']) - + # Measured, not a fixed -7: the detail font is 6px in most plugins + # but 10px in soccer and nrl, where "Sep 19" ran 5px past the card. + date_bottom = draw.textbbox((0, 0), date_text, font=date_font)[3] + date_y = max(0, self.display_height - date_bottom - 1) + self._draw_text_with_outline( + draw, date_text, (date_x, date_y), date_font + ) + def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None: """Draw odds with dynamic positioning.""" try: diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index 2fde071a..f3e35566 100644 --- a/plugins/afl-scoreboard/manifest.json +++ b/plugins/afl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "afl-scoreboard", "name": "AFL Scoreboard", - "version": "1.4.0", + "version": "1.5.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "afl_upcoming" ], "versions": [ + { + "version": "1.5.0", + "released": "2026-08-06", + "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.4.0", "released": "2026-08-05", diff --git a/plugins/afl-scoreboard/scroll_display.py b/plugins/afl-scoreboard/scroll_display.py index 0dc80f73..0d3b2333 100644 --- a/plugins/afl-scoreboard/scroll_display.py +++ b/plugins/afl-scoreboard/scroll_display.py @@ -209,7 +209,7 @@ def prepare_scroll_content( # Get scroll settings scroll_settings = self._get_scroll_settings() - gap_between_games = scroll_settings.get("gap_between_games", 24) + gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) game_card_width = scroll_settings.get("game_card_width", 128) @@ -272,7 +272,10 @@ def prepare_scroll_content( # Add horizontal padding to prevent logos from being cut off at edges # Logos are positioned at -10 and display_width+10, so we need padding - padding = 12 # Padding on each side to ensure logos aren't cut off + # Half the gap each side, so adjacent cards are separated by exactly + # gap_between_games. Baking it into the card matters for Vegas, which + # stitches its own items and never sees the scroll helper item_gap. + padding = max(4, gap_between_games // 2) padded_width = game_img.width + (padding * 2) padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) padded_img.paste(game_img, (padding, 0)) @@ -294,7 +297,7 @@ def prepare_scroll_content( # Create scrolling image using ScrollHelper self.scroll_helper.create_scrolling_image( content_items, - item_gap=gap_between_games, + item_gap=0, # spacing already baked into each card element_gap=0 # No element gap - each item is a complete game card ) diff --git a/plugins/afl-scoreboard/scroll_display_legacy.py b/plugins/afl-scoreboard/scroll_display_legacy.py index bccfd799..a3a4b1ad 100644 --- a/plugins/afl-scoreboard/scroll_display_legacy.py +++ b/plugins/afl-scoreboard/scroll_display_legacy.py @@ -299,7 +299,7 @@ def prepare_scroll_content( # Get scroll settings scroll_settings = self._get_scroll_settings() - gap_between_games = scroll_settings.get("gap_between_games", 24) + gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) game_card_width = scroll_settings.get("game_card_width", 128) @@ -362,7 +362,10 @@ def prepare_scroll_content( # Add horizontal padding to prevent logos from being cut off at edges # Logos are positioned at -10 and display_width+10, so we need padding - padding = 12 # Padding on each side to ensure logos aren't cut off + # Half the gap each side, so adjacent cards are separated by exactly + # gap_between_games. Baking it into the card matters for Vegas, which + # stitches its own items and never sees the scroll helper item_gap. + padding = max(4, gap_between_games // 2) padded_width = game_img.width + (padding * 2) padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) padded_img.paste(game_img, (padding, 0)) @@ -384,7 +387,7 @@ def prepare_scroll_content( # Create scrolling image using ScrollHelper self.scroll_helper.create_scrolling_image( content_items, - item_gap=gap_between_games, + item_gap=0, # spacing already baked into each card element_gap=0 # No element gap - each item is a complete game card ) diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index f3581f7c..5ceaa1ae 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -4,6 +4,41 @@ "description": "Configuration schema for the Baseball Scoreboard plugin - displays live, recent, and upcoming MLB, MiLB, and NCAA Baseball games", "type": "object", "properties": { + "scroll_card": { + "type": "object", + "title": "Scroll & Vegas Card Layout", + "description": "Layout of the game cards built for scroll and Vegas modes. The full-screen scoreboard is drawn separately and is not affected by these settings.", + "x-advanced": true, + "properties": { + "upcoming_center": { + "type": "string", + "title": "Middle of an Upcoming Card", + "description": "What to show between the two logos before a game starts: VS, or the date and time stacked in the middle. Upcoming games never show a score, since the game has not been played.", + "enum": [ + "vs", + "date_time" + ], + "default": "vs" + }, + "date_format": { + "type": "string", + "title": "Date Format", + "description": "How to write the date on an upcoming card: abbrev shows \"Sep 19\", numeric shows \"9/19\".", + "enum": [ + "abbrev", + "numeric" + ], + "default": "abbrev" + }, + "center_gap": { + "type": "integer", + "title": "Center Gap", + "description": "Pixels kept clear down the middle of a card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", + "minimum": 0, + "maximum": 64 + } + } + }, "enabled": { "type": "boolean", "default": true, diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index 69120717..aed96370 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -195,7 +195,7 @@ def _load_and_resize_logo(self, league: str, team_abbrev: str) -> Optional[Image max_logo_w = self.display_width // 3 max_logo_h = self.display_height else: - max_logo_w = min(self.display_height, self.display_width // 2) + max_logo_w = self._logo_slot_width() max_logo_h = int(self.display_height * 0.75) logo.thumbnail((max_logo_w, max_logo_h), RESAMPLE_FILTER) @@ -401,7 +401,7 @@ def _render_live_game(self, game: Dict) -> Image.Image: center_y = self.display_height // 2 # Logos - logo_slot = min(self.display_height, self.display_width // 2) + logo_slot = self._logo_slot_width() away_x = (logo_slot - away_logo.width) // 2 main_img.paste(away_logo, (away_x, center_y - away_logo.height // 2), away_logo) home_x = (self.display_width - logo_slot) + (logo_slot - home_logo.width) // 2 @@ -554,7 +554,7 @@ def _render_recent_game(self, game: Dict) -> Image.Image: center_y = self.display_height // 2 # Logos (tighter fit for recent) - logo_slot = min(self.display_height, self.display_width // 2) + logo_slot = self._logo_slot_width() away_x = (logo_slot - away_logo.width) // 2 main_img.paste(away_logo, (away_x, center_y - away_logo.height // 2), away_logo) home_x = (self.display_width - logo_slot) + (logo_slot - home_logo.width) // 2 @@ -590,6 +590,106 @@ def _render_recent_game(self, game: Dict) -> Image.Image: self.logger.exception("Error rendering recent game") return self._render_error_card("Display error") + # ------------------------------------------------------------------ + # Scroll/Vegas card options -- config["scroll_card"]. + # + # These only affect the cards this renderer builds, which are used by + # scroll_display.py and scroll_display_legacy.py alone. The full-screen + # scorebug is drawn elsewhere and is deliberately left untouched. + # ------------------------------------------------------------------ + # Middle strip kept clear of logos so the score / "VS" is never drawn on + # top of them. 0.28 of a 128px card clears "1-2" (30px) with room spare. + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + # 22 so "VS" (20px) still clears the logos on the narrowest 64px card. + CENTER_GAP_MIN_PX: ClassVar[int] = 22 + CENTER_GAP_MAX_PX: ClassVar[int] = 40 + _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ) + + def _scroll_card_option(self, key: str, default: Any = None) -> Any: + """Read one key from the scroll_card config block.""" + block = (self.config or {}).get("scroll_card") + if isinstance(block, dict) and block.get(key) is not None: + return block.get(key) + return default + + def _center_gap_width(self) -> int: + """Width of the middle strip kept clear of logos. + + ``scroll_card.center_gap`` overrides it; 0 restores edge-to-edge logos. + """ + configured = self._scroll_card_option("center_gap") + if isinstance(configured, (int, float)) and configured >= 0: + return int(configured) + scaled = round(self.display_width * self.CENTER_GAP_RATIO) + return int(max(self.CENTER_GAP_MIN_PX, min(self.CENTER_GAP_MAX_PX, scaled))) + + def _logo_slot_width(self) -> int: + """Per-side logo slot, leaving the center gap clear. + + Capped at display_height, so wide/short cards (128x32, 256x32) already + have a large middle and come out unchanged -- only the sizes where the + logos used to meet (128x64, 64x32) shrink. + """ + available = (self.display_width - self._center_gap_width()) // 2 + return max(8, min(self.display_height, available)) + + def _upcoming_center_mode(self) -> str: + """What sits in the middle of an upcoming card: 'vs' or 'date_time'.""" + mode = str(self._scroll_card_option("upcoming_center", "vs") or "vs").lower() + return mode if mode in ("vs", "date_time") else "vs" + + def _format_game_date(self, date_text: str) -> str: + """Render a date as "Sep 19" (default) or "9/19" (``date_format``).""" + raw = str(date_text or "").strip() + if not raw or self._scroll_card_option("date_format", "abbrev") == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + if len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): + month = int(parts[0]) + if 1 <= month <= 12: + return f"{self._MONTH_ABBR[month - 1]} {int(parts[1])}" + return raw + + def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: + """Draw the middle of an upcoming card. + + Never a score: an upcoming game has not started, so the extractor's + 0-0 is noise. Either "VS" (default) or the date and time stacked. + """ + if self._upcoming_center_mode() == "vs": + vs_text = "VS" + vs_width = draw.textlength(vs_text, font=self.fonts['score']) + vs_x = (self.display_width - vs_width) // 2 + vs_y = (self.display_height // 2) - 3 + self._draw_text_with_outline( + draw, vs_text, (vs_x, vs_y), self.fonts['score'] + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + date_text = self._format_game_date(date_text) + font = self.fonts.get('detail') or self.fonts['time'] + lines = [t for t in (date_text, time_text) if t] + if not lines: + return + line_h = 7 + top = (self.display_height // 2) - (len(lines) * line_h) // 2 + for i, line in enumerate(lines): + width = draw.textlength(line, font=font) + self._draw_text_with_outline( + draw, line, ((self.display_width - width) // 2, top + i * line_h), font + ) + + def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: + """(date, time) for an upcoming card, from the extractor's flat keys.""" + return ( + str(game.get("game_date", "") or ""), + str(game.get("game_time", "") or ""), + ) + def _render_upcoming_game(self, game: Dict) -> Image.Image: """Render an upcoming baseball game card.""" try: @@ -607,18 +707,12 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: center_y = self.display_height // 2 # Logos (tighter fit) - logo_slot = min(self.display_height, self.display_width // 2) + logo_slot = self._logo_slot_width() away_x = (logo_slot - away_logo.width) // 2 main_img.paste(away_logo, (away_x, center_y - away_logo.height // 2), away_logo) home_x = (self.display_width - logo_slot) + (logo_slot - home_logo.width) // 2 main_img.paste(home_logo, (home_x, center_y - home_logo.height // 2), home_logo) - # "Next Game" (top center) - status_font = self.fonts['status'] if self.display_width <= 128 else self.fonts['time'] - status_text = "Next Game" - status_width = draw.textlength(status_text, font=status_font) - self._draw_text_with_outline(draw, status_text, ((self.display_width - status_width) // 2, 1), status_font) - # Game time/date from start_time start_time = game.get('start_time', '') game_date = '' @@ -628,20 +722,33 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: dt = datetime.fromisoformat(start_time.replace('Z', '+00:00')) local_tz = resolve_timezone(config=self.config, log=self.logger) dt_local = dt.astimezone(local_tz) - game_date = dt_local.strftime('%b %d') - game_time = dt_local.strftime('%-I:%M %p') + # Numeric here; _format_game_date turns it into "Sep 19" + # unless the user asked for the numeric form. + game_date = dt_local.strftime('%-m/%-d') + game_time = dt_local.strftime('%-I:%M%p') except (ValueError, AttributeError): game_time = start_time[:10] if len(start_time) > 10 else start_time - time_font = self.fonts['time'] - if game_date: - date_width = draw.textlength(game_date, font=time_font) - draw_y = center_y - 7 - self._draw_text_with_outline(draw, game_date, ((self.display_width - date_width) // 2, draw_y), time_font) - if game_time: - time_width = draw.textlength(game_time, font=time_font) - draw_y = center_y + 2 - self._draw_text_with_outline(draw, game_time, ((self.display_width - time_width) // 2, draw_y), time_font) + game_date = self._format_game_date(game_date) + + # Matches the other sports' cards: VS (or the stacked date/time) + # in the middle, time top-center and date bottom-center. + self._draw_upcoming_center(draw, dict(game, game_date=game_date, + game_time=game_time)) + if self._upcoming_center_mode() == "vs": + if game_time: + time_width = draw.textlength(game_time, font=self.fonts['time']) + self._draw_text_with_outline( + draw, game_time, + ((self.display_width - time_width) // 2, 1), self.fonts['time']) + if game_date: + date_font = self.fonts.get('detail') or self.fonts['time'] + date_width = draw.textlength(game_date, font=date_font) + date_bottom = draw.textbbox((0, 0), game_date, font=date_font)[3] + self._draw_text_with_outline( + draw, game_date, + ((self.display_width - date_width) // 2, + max(0, self.display_height - date_bottom - 1)), date_font) # Records at bottom corners self._draw_records(draw, game) diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index b7957532..811a5356 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.23.0", + "version": "1.24.0", "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": [ + { + "version": "1.24.0", + "released": "2026-08-06", + "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.23.0", "released": "2026-08-05", diff --git a/plugins/baseball-scoreboard/scroll_display.py b/plugins/baseball-scoreboard/scroll_display.py index 5f19e52d..5898d954 100644 --- a/plugins/baseball-scoreboard/scroll_display.py +++ b/plugins/baseball-scoreboard/scroll_display.py @@ -202,7 +202,7 @@ def prepare_scroll_content( # Get scroll settings scroll_settings = self._get_scroll_settings() - gap_between_games = scroll_settings.get("gap_between_games", 24) + gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) game_card_width = scroll_settings.get("game_card_width", self.display_width) @@ -245,7 +245,10 @@ def prepare_scroll_content( # Only pad when card is narrower than the viewport; full-width cards # need no padding or the card becomes wider than the display. - padding = 0 if game_img.width >= self.display_width else 12 + # Half the gap each side so Vegas, which stitches its own + # items, separates cards by exactly gap_between_games. + padding = (0 if game_img.width >= self.display_width + else max(4, gap_between_games // 2)) padded_width = game_img.width + (padding * 2) padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) padded_img.paste(game_img, (padding, 0)) @@ -267,7 +270,7 @@ def prepare_scroll_content( # Create scrolling image using ScrollHelper self.scroll_helper.create_scrolling_image( content_items, - item_gap=gap_between_games, + item_gap=0, # spacing already baked into each card element_gap=0 # No element gap - each item is a complete game card ) diff --git a/plugins/baseball-scoreboard/scroll_display_legacy.py b/plugins/baseball-scoreboard/scroll_display_legacy.py index a5ef29a3..67a49067 100644 --- a/plugins/baseball-scoreboard/scroll_display_legacy.py +++ b/plugins/baseball-scoreboard/scroll_display_legacy.py @@ -356,7 +356,7 @@ def prepare_scroll_content( # Get scroll settings scroll_settings = self._get_scroll_settings() - gap_between_games = scroll_settings.get("gap_between_games", 24) + gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) game_card_width = scroll_settings.get("game_card_width", self.display_width) @@ -399,7 +399,10 @@ def prepare_scroll_content( # Only pad when card is narrower than the viewport; full-width cards # need no padding or the card becomes wider than the display. - padding = 0 if game_img.width >= self.display_width else 12 + # Half the gap each side so Vegas, which stitches its own + # items, separates cards by exactly gap_between_games. + padding = (0 if game_img.width >= self.display_width + else max(4, gap_between_games // 2)) padded_width = game_img.width + (padding * 2) padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) padded_img.paste(game_img, (padding, 0)) @@ -421,7 +424,7 @@ def prepare_scroll_content( # Create scrolling image using ScrollHelper self.scroll_helper.create_scrolling_image( content_items, - item_gap=gap_between_games, + item_gap=0, # spacing already baked into each card element_gap=0 # No element gap - each item is a complete game card ) diff --git a/plugins/basketball-scoreboard/config_schema.json b/plugins/basketball-scoreboard/config_schema.json index 5b813de6..858d5750 100644 --- a/plugins/basketball-scoreboard/config_schema.json +++ b/plugins/basketball-scoreboard/config_schema.json @@ -4,6 +4,41 @@ "description": "Configuration schema for the Basketball Scoreboard plugin - displays live, recent, and upcoming NBA, WNBA, NCAA Men's, and NCAA Women's basketball games", "type": "object", "properties": { + "scroll_card": { + "type": "object", + "title": "Scroll & Vegas Card Layout", + "description": "Layout of the game cards built for scroll and Vegas modes. The full-screen scoreboard is drawn separately and is not affected by these settings.", + "x-advanced": true, + "properties": { + "upcoming_center": { + "type": "string", + "title": "Middle of an Upcoming Card", + "description": "What to show between the two logos before a game starts: VS, or the date and time stacked in the middle. Upcoming games never show a score, since the game has not been played.", + "enum": [ + "vs", + "date_time" + ], + "default": "vs" + }, + "date_format": { + "type": "string", + "title": "Date Format", + "description": "How to write the date on an upcoming card: abbrev shows \"Sep 19\", numeric shows \"9/19\".", + "enum": [ + "abbrev", + "numeric" + ], + "default": "abbrev" + }, + "center_gap": { + "type": "integer", + "title": "Center Gap", + "description": "Pixels kept clear down the middle of a card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", + "minimum": 0, + "maximum": 64 + } + } + }, "enabled": { "type": "boolean", "default": true, diff --git a/plugins/basketball-scoreboard/game_renderer.py b/plugins/basketball-scoreboard/game_renderer.py index 520aaa7c..187d4d7b 100644 --- a/plugins/basketball-scoreboard/game_renderer.py +++ b/plugins/basketball-scoreboard/game_renderer.py @@ -193,7 +193,7 @@ def preload_logos(self, games: list, logo_dir: Path) -> None: if logo_path: logo = self._load_and_resize_logo(abbr, logo_path, league) if logo: - self._logo_cache[cache_key] = logo + self._logo_cache[self._logo_cache_key(cache_key)] = logo self.logger.debug(f"Preloaded {len(self._logo_cache)} team logos") @@ -217,7 +217,7 @@ def _load_and_resize_logo( # Use league+abbrev as cache key to avoid cross-league collisions cache_key = f"{league}:{team_abbrev}" if cache_key in self._logo_cache: - return self._logo_cache[cache_key] + return self._logo_cache[self._logo_cache_key(cache_key)] try: # Try to load from path @@ -232,12 +232,12 @@ def _load_and_resize_logo( bbox = img.getbbox() if bbox: img = img.crop(bbox) - img.thumbnail((self.display_height, self.display_height), resample=RESAMPLE_FILTER) + img.thumbnail((self._logo_slot_width(), self.display_height), resample=RESAMPLE_FILTER) # Copy before context manager closes file handle logo = img.copy() - self._logo_cache[cache_key] = logo + self._logo_cache[self._logo_cache_key(cache_key)] = logo return logo else: # Try to load from league-specific logo directory @@ -251,12 +251,12 @@ def _load_and_resize_logo( bbox = img.getbbox() if bbox: img = img.crop(bbox) - img.thumbnail((self.display_height, self.display_height), resample=RESAMPLE_FILTER) + img.thumbnail((self._logo_slot_width(), self.display_height), resample=RESAMPLE_FILTER) # Copy before context manager closes file handle logo = img.copy() - self._logo_cache[cache_key] = logo + self._logo_cache[self._logo_cache_key(cache_key)] = logo return logo else: self.logger.debug(f"Logo not found at {logo_path} or {logo_file}") @@ -477,7 +477,7 @@ def render_game_card( # Draw logos — each centered within a slot on its side; cap at half the card # width so home_slot_start stays non-negative on square/tall displays - logo_slot = min(self.display_height, self.display_width // 2) + logo_slot = self._logo_slot_width() away_x = (logo_slot - away_logo.width) // 2 away_y = center_y - (away_logo.height // 2) main_img.paste(away_logo, (away_x, away_y), away_logo) @@ -487,18 +487,22 @@ def render_game_card( home_y = center_y - (home_logo.height // 2) main_img.paste(home_logo, (home_x, home_y), home_logo) - # Draw scores (centered) - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 - self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts['score'], - fill=self._score_color_for(game, game_type) - ) - + # Draw scores (centered) — only once a game has started. Upcoming games + # have no score, so the extractor's 0-0 was pure noise. + if game_type in ("live", "recent"): + home_score = str(game.get("home_score", "0")) + away_score = str(game.get("away_score", "0")) + score_text = f"{away_score}-{home_score}" + score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) + score_x = (self.display_width - score_width) // 2 + score_y = (self.display_height // 2) - 3 + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts['score'], + fill=self._score_color_for(game, game_type) + ) + elif game_type == "upcoming": + self._draw_upcoming_center(draw_overlay, game) + # Draw period/status based on game type if game_type == "live": self._draw_live_game_status(draw_overlay, game) @@ -566,39 +570,145 @@ def _draw_recent_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: date_y = self.display_height - 7 self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['detail']) + # ------------------------------------------------------------------ + # Scroll/Vegas card options -- config["scroll_card"]. + # + # These only affect the cards this renderer builds, which are used by + # scroll_display.py and scroll_display_legacy.py alone. The full-screen + # scorebug is drawn elsewhere and is deliberately left untouched. + # ------------------------------------------------------------------ + # Middle strip kept clear of logos so the score / "VS" is never drawn on + # top of them. 0.28 of a 128px card clears "1-2" (30px) with room spare. + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + # 22 so "VS" (20px) still clears the logos on the narrowest 64px card. + CENTER_GAP_MIN_PX: ClassVar[int] = 22 + CENTER_GAP_MAX_PX: ClassVar[int] = 40 + _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ) + + def _logo_cache_key(self, name: str) -> str: + """Cache key scoped to the logo slot. + + One cache dict is shared by renderers built for different card widths, + so a logo sized for a wide slot must not be handed to a narrow one. + """ + return f"{name}@{self._logo_slot_width()}x{self.display_height}" + + def _scroll_card_option(self, key: str, default: Any = None) -> Any: + """Read one key from the scroll_card config block.""" + block = (self.config or {}).get("scroll_card") + if isinstance(block, dict) and block.get(key) is not None: + return block.get(key) + return default + + def _center_gap_width(self) -> int: + """Width of the middle strip kept clear of logos. + + ``scroll_card.center_gap`` overrides it; 0 restores edge-to-edge logos. + """ + configured = self._scroll_card_option("center_gap") + if isinstance(configured, (int, float)) and configured >= 0: + return int(configured) + scaled = round(self.display_width * self.CENTER_GAP_RATIO) + return int(max(self.CENTER_GAP_MIN_PX, min(self.CENTER_GAP_MAX_PX, scaled))) + + def _logo_slot_width(self) -> int: + """Per-side logo slot, leaving the center gap clear. + + Capped at display_height, so wide/short cards (128x32, 256x32) already + have a large middle and come out unchanged -- only the sizes where the + logos used to meet (128x64, 64x32) shrink. + """ + available = (self.display_width - self._center_gap_width()) // 2 + return max(8, min(self.display_height, available)) + + def _upcoming_center_mode(self) -> str: + """What sits in the middle of an upcoming card: 'vs' or 'date_time'.""" + mode = str(self._scroll_card_option("upcoming_center", "vs") or "vs").lower() + return mode if mode in ("vs", "date_time") else "vs" + + def _format_game_date(self, date_text: str) -> str: + """Render a date as "Sep 19" (default) or "9/19" (``date_format``).""" + raw = str(date_text or "").strip() + if not raw or self._scroll_card_option("date_format", "abbrev") == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + if len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): + month = int(parts[0]) + if 1 <= month <= 12: + return f"{self._MONTH_ABBR[month - 1]} {int(parts[1])}" + return raw + + def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: + """Draw the middle of an upcoming card. + + Never a score: an upcoming game has not started, so the extractor's + 0-0 is noise. Either "VS" (default) or the date and time stacked. + """ + if self._upcoming_center_mode() == "vs": + vs_text = "VS" + vs_width = draw.textlength(vs_text, font=self.fonts['score']) + vs_x = (self.display_width - vs_width) // 2 + vs_y = (self.display_height // 2) - 3 + self._draw_text_with_outline( + draw, vs_text, (vs_x, vs_y), self.fonts['score'] + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + date_text = self._format_game_date(date_text) + font = self.fonts.get('detail') or self.fonts['time'] + lines = [t for t in (date_text, time_text) if t] + if not lines: + return + line_h = 7 + top = (self.display_height // 2) - (len(lines) * line_h) // 2 + for i, line in enumerate(lines): + width = draw.textlength(line, font=font) + self._draw_text_with_outline( + draw, line, ((self.display_width - width) // 2, top + i * line_h), font + ) + + def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: + """(date, time) for an upcoming card, from the extractor's flat keys.""" + return ( + str(game.get("game_date", "") or ""), + str(game.get("game_time", "") or ""), + ) + def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: - """Draw status elements for an upcoming basketball game.""" - # Status text - tournament round or "Next Game" - if self._get_mm_setting(game, 'show_round') and game.get("is_tournament") and game.get("tournament_round"): - status_text = game["tournament_round"] - if self._get_mm_setting(game, 'show_region', False) and game.get("tournament_region"): - status_text = f"{status_text} {game['tournament_region']}" - else: - status_text = "Next Game" - status_font = self.fonts['status'] - if self.display_width > 128: - status_font = self.fonts['time'] - status_width = draw.textlength(status_text, font=status_font) - status_x = (self.display_width - status_width) // 2 - status_y = 1 - self._draw_text_with_outline(draw, status_text, (status_x, status_y), status_font) + """Draw date/time around an upcoming card: time top, date bottom. - # Game date and time - use flat format from sports.py - game_date = game.get("game_date", "") - game_time = game.get("game_time", "") + Skipped when the date and time are stacked in the middle instead -- + drawing both would print them twice. + """ + if self._upcoming_center_mode() != "vs": + return - if game_date: - date_width = draw.textlength(game_date, font=self.fonts['time']) - date_x = (self.display_width - date_width) // 2 - date_y = (self.display_height // 2) - 7 - self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['time']) + date_text, time_text = self._upcoming_date_and_time(game) + date_text = self._format_game_date(date_text) - if game_time: - time_width = draw.textlength(game_time, font=self.fonts['time']) + if time_text: + time_width = draw.textlength(time_text, font=self.fonts['time']) time_x = (self.display_width - time_width) // 2 - time_y = (self.display_height // 2) - 7 + 9 - self._draw_text_with_outline(draw, game_time, (time_x, time_y), self.fonts['time']) - + self._draw_text_with_outline( + draw, time_text, (time_x, 1), self.fonts['time'] + ) + + if date_text: + date_font = self.fonts.get('detail') or self.fonts['time'] + date_width = draw.textlength(date_text, font=date_font) + date_x = (self.display_width - date_width) // 2 + # Measured, not a fixed -7: the detail font is 6px in most plugins + # but 10px in soccer and nrl, where "Sep 19" ran 5px past the card. + date_bottom = draw.textbbox((0, 0), date_text, font=date_font)[3] + date_y = max(0, self.display_height - date_bottom - 1) + self._draw_text_with_outline( + draw, date_text, (date_x, date_y), date_font + ) + def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: """Get layout offset for a specific element and axis from config.""" try: diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 626baf64..b71aaecf 100644 --- a/plugins/basketball-scoreboard/manifest.json +++ b/plugins/basketball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "basketball-scoreboard", "name": "Basketball Scoreboard", - "version": "1.11.0", + "version": "1.12.0", "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores, schedules, and March Madness tournament support", "author": "ChuckBuilds", "category": "sports", @@ -18,6 +18,12 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.12.0", + "released": "2026-08-06", + "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.11.0", "released": "2026-08-05", diff --git a/plugins/basketball-scoreboard/scroll_display.py b/plugins/basketball-scoreboard/scroll_display.py index 4ab0569d..9a0dfcde 100644 --- a/plugins/basketball-scoreboard/scroll_display.py +++ b/plugins/basketball-scoreboard/scroll_display.py @@ -213,7 +213,7 @@ def prepare_scroll_content( # Get scroll settings scroll_settings = self._get_scroll_settings() - gap_between_games = scroll_settings.get("gap_between_games", 24) + gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) game_card_width = scroll_settings.get("game_card_width", 128) @@ -284,7 +284,10 @@ def prepare_scroll_content( # Add horizontal padding to prevent logos from being cut off at edges # Logos are positioned at -10 and display_width+10, so we need padding - padding = 12 # Padding on each side to ensure logos aren't cut off + # Half the gap each side, so adjacent cards are separated by exactly + # gap_between_games. Baking it into the card matters for Vegas, which + # stitches its own items and never sees the scroll helper item_gap. + padding = max(4, gap_between_games // 2) padded_width = game_img.width + (padding * 2) padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) padded_img.paste(game_img, (padding, 0)) @@ -306,7 +309,7 @@ def prepare_scroll_content( # Create scrolling image using ScrollHelper self.scroll_helper.create_scrolling_image( content_items, - item_gap=gap_between_games, + item_gap=0, # spacing already baked into each card element_gap=0 # No element gap - each item is a complete game card ) diff --git a/plugins/basketball-scoreboard/scroll_display_legacy.py b/plugins/basketball-scoreboard/scroll_display_legacy.py index 29de7b4d..8213b396 100644 --- a/plugins/basketball-scoreboard/scroll_display_legacy.py +++ b/plugins/basketball-scoreboard/scroll_display_legacy.py @@ -352,7 +352,7 @@ def prepare_scroll_content( # Get scroll settings scroll_settings = self._get_scroll_settings() - gap_between_games = scroll_settings.get("gap_between_games", 24) + gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) game_card_width = scroll_settings.get("game_card_width", 128) @@ -423,7 +423,10 @@ def prepare_scroll_content( # Add horizontal padding to prevent logos from being cut off at edges # Logos are positioned at -10 and display_width+10, so we need padding - padding = 12 # Padding on each side to ensure logos aren't cut off + # Half the gap each side, so adjacent cards are separated by exactly + # gap_between_games. Baking it into the card matters for Vegas, which + # stitches its own items and never sees the scroll helper item_gap. + padding = max(4, gap_between_games // 2) padded_width = game_img.width + (padding * 2) padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) padded_img.paste(game_img, (padding, 0)) @@ -445,7 +448,7 @@ def prepare_scroll_content( # Create scrolling image using ScrollHelper self.scroll_helper.create_scrolling_image( content_items, - item_gap=gap_between_games, + item_gap=0, # spacing already baked into each card element_gap=0 # No element gap - each item is a complete game card ) diff --git a/plugins/football-scoreboard/config_schema.json b/plugins/football-scoreboard/config_schema.json index 7686097d..ff8301b4 100644 --- a/plugins/football-scoreboard/config_schema.json +++ b/plugins/football-scoreboard/config_schema.json @@ -4,6 +4,41 @@ "description": "Configuration schema for the Football Scoreboard plugin - displays live, recent, and upcoming NFL and NCAA Football games", "type": "object", "properties": { + "scroll_card": { + "type": "object", + "title": "Scroll & Vegas Card Layout", + "description": "Layout of the game cards built for scroll and Vegas modes. The full-screen scoreboard is drawn separately and is not affected by these settings.", + "x-advanced": true, + "properties": { + "upcoming_center": { + "type": "string", + "title": "Middle of an Upcoming Card", + "description": "What to show between the two logos before a game starts: VS, or the date and time stacked in the middle. Upcoming games never show a score, since the game has not been played.", + "enum": [ + "vs", + "date_time" + ], + "default": "vs" + }, + "date_format": { + "type": "string", + "title": "Date Format", + "description": "How to write the date on an upcoming card: abbrev shows \"Sep 19\", numeric shows \"9/19\".", + "enum": [ + "abbrev", + "numeric" + ], + "default": "abbrev" + }, + "center_gap": { + "type": "integer", + "title": "Center Gap", + "description": "Pixels kept clear down the middle of a card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", + "minimum": 0, + "maximum": 64 + } + } + }, "enabled": { "type": "boolean", "default": true, diff --git a/plugins/football-scoreboard/game_renderer.py b/plugins/football-scoreboard/game_renderer.py index 1cf52a09..3a7d309f 100644 --- a/plugins/football-scoreboard/game_renderer.py +++ b/plugins/football-scoreboard/game_renderer.py @@ -305,7 +305,7 @@ def preload_logos(self, games: list, logo_dir: Path) -> None: game.get(f'{team_key.replace("abbr", "logo_url")}') ) if logo: - self._logo_cache[abbr] = logo + self._logo_cache[self._logo_cache_key(abbr)] = logo self.logger.debug(f"Preloaded {len(self._logo_cache)} team logos") @@ -318,7 +318,7 @@ def _load_and_resize_logo( ) -> Optional[Image.Image]: """Load and resize a team logo with caching.""" if team_abbrev in self._logo_cache: - return self._logo_cache[team_abbrev] + return self._logo_cache[self._logo_cache_key(team_abbrev)] try: # Try to load from path @@ -333,9 +333,9 @@ def _load_and_resize_logo( bbox = logo.getbbox() if bbox: logo = logo.crop(bbox) - logo.thumbnail((self.display_height, self.display_height), Image.Resampling.LANCZOS) + logo.thumbnail((self._logo_slot_width(), self.display_height), Image.Resampling.LANCZOS) - self._logo_cache[team_abbrev] = logo + self._logo_cache[self._logo_cache_key(team_abbrev)] = logo return logo else: self.logger.debug(f"Logo not found at {logo_path}") @@ -559,9 +559,9 @@ def render_game_card( center_y = self.display_height // 2 - # Draw logos — each centered within a slot on its side; cap at half the card - # width so home_slot_start stays non-negative on square/tall displays - logo_slot = min(self.display_height, self.display_width // 2) + # Draw logos — each centered within a slot on its side, leaving the + # centre gap clear so the score is never drawn on top of a logo. + logo_slot = self._logo_slot_width() away_x = (logo_slot - away_logo.width) // 2 away_y = center_y - (away_logo.height // 2) main_img.paste(away_logo, (away_x, away_y), away_logo) @@ -570,19 +570,23 @@ def render_game_card( home_x = home_slot_start + (logo_slot - home_logo.width) // 2 home_y = center_y - (home_logo.height // 2) main_img.paste(home_logo, (home_x, home_y), home_logo) - - # Draw scores (centered) - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 - self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts['score'], - fill=self._score_color_for(game, game_type) - ) - + + # Draw scores (centered) — only once a game has started. Upcoming games + # have no score, so the extractor's 0-0 was pure noise. + if game_type in ("live", "recent"): + home_score = str(game.get("home_score", "0")) + away_score = str(game.get("away_score", "0")) + score_text = f"{away_score}-{home_score}" + score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) + score_x = (self.display_width - score_width) // 2 + score_y = (self.display_height // 2) - 3 + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts['score'], + fill=self._score_color_for(game, game_type) + ) + elif game_type == "upcoming": + self._draw_upcoming_center(draw_overlay, game) + # Draw period/status based on game type if game_type == "live": self._draw_live_game_status(draw_overlay, game) @@ -1019,24 +1023,145 @@ def _draw_recent_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: date_y = self.display_height - 7 self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['detail']) + # ------------------------------------------------------------------ + # Scroll/Vegas card options -- config["scroll_card"]. + # + # These only affect the cards this renderer builds, which are used by + # scroll_display.py and scroll_display_legacy.py alone. The full-screen + # scorebug is drawn elsewhere and is deliberately left untouched. + # ------------------------------------------------------------------ + # Middle strip kept clear of logos so the score / "VS" is never drawn on + # top of them. 0.28 of a 128px card clears "1-2" (30px) with room spare. + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + # 22 so "VS" (20px) still clears the logos on the narrowest 64px card. + CENTER_GAP_MIN_PX: ClassVar[int] = 22 + CENTER_GAP_MAX_PX: ClassVar[int] = 40 + _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ) + + def _logo_cache_key(self, name: str) -> str: + """Cache key scoped to the logo slot. + + One cache dict is shared by renderers built for different card widths, + so a logo sized for a wide slot must not be handed to a narrow one. + """ + return f"{name}@{self._logo_slot_width()}x{self.display_height}" + + def _scroll_card_option(self, key: str, default: Any = None) -> Any: + """Read one key from the scroll_card config block.""" + block = (self.config or {}).get("scroll_card") + if isinstance(block, dict) and block.get(key) is not None: + return block.get(key) + return default + + def _center_gap_width(self) -> int: + """Width of the middle strip kept clear of logos. + + ``scroll_card.center_gap`` overrides it; 0 restores edge-to-edge logos. + """ + configured = self._scroll_card_option("center_gap") + if isinstance(configured, (int, float)) and configured >= 0: + return int(configured) + scaled = round(self.display_width * self.CENTER_GAP_RATIO) + return int(max(self.CENTER_GAP_MIN_PX, min(self.CENTER_GAP_MAX_PX, scaled))) + + def _logo_slot_width(self) -> int: + """Per-side logo slot, leaving the center gap clear. + + Capped at display_height, so wide/short cards (128x32, 256x32) already + have a large middle and come out unchanged -- only the sizes where the + logos used to meet (128x64, 64x32) shrink. + """ + available = (self.display_width - self._center_gap_width()) // 2 + return max(8, min(self.display_height, available)) + + def _upcoming_center_mode(self) -> str: + """What sits in the middle of an upcoming card: 'vs' or 'date_time'.""" + mode = str(self._scroll_card_option("upcoming_center", "vs") or "vs").lower() + return mode if mode in ("vs", "date_time") else "vs" + + def _format_game_date(self, date_text: str) -> str: + """Render a date as "Sep 19" (default) or "9/19" (``date_format``).""" + raw = str(date_text or "").strip() + if not raw or self._scroll_card_option("date_format", "abbrev") == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + if len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): + month = int(parts[0]) + if 1 <= month <= 12: + return f"{self._MONTH_ABBR[month - 1]} {int(parts[1])}" + return raw + + def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: + """Draw the middle of an upcoming card. + + Never a score: an upcoming game has not started, so the extractor's + 0-0 is noise. Either "VS" (default) or the date and time stacked. + """ + if self._upcoming_center_mode() == "vs": + vs_text = "VS" + vs_width = draw.textlength(vs_text, font=self.fonts['score']) + vs_x = (self.display_width - vs_width) // 2 + vs_y = (self.display_height // 2) - 3 + self._draw_text_with_outline( + draw, vs_text, (vs_x, vs_y), self.fonts['score'] + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + date_text = self._format_game_date(date_text) + font = self.fonts.get('detail') or self.fonts['time'] + lines = [t for t in (date_text, time_text) if t] + if not lines: + return + line_h = 7 + top = (self.display_height // 2) - (len(lines) * line_h) // 2 + for i, line in enumerate(lines): + width = draw.textlength(line, font=font) + self._draw_text_with_outline( + draw, line, ((self.display_width - width) // 2, top + i * line_h), font + ) + + def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: + """(date, time) for an upcoming card, from the extractor's flat keys.""" + return ( + str(game.get("game_date", "") or ""), + str(game.get("game_time", "") or ""), + ) + def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: - """Draw status elements for an upcoming game.""" - # Game time (Top center) - game_time = game.get("game_time", "") - if game_time: - time_width = draw.textlength(game_time, font=self.fonts['time']) + """Draw date/time around an upcoming card: time top, date bottom. + + Skipped when the date and time are stacked in the middle instead -- + drawing both would print them twice. + """ + if self._upcoming_center_mode() != "vs": + return + + date_text, time_text = self._upcoming_date_and_time(game) + date_text = self._format_game_date(date_text) + + if time_text: + time_width = draw.textlength(time_text, font=self.fonts['time']) time_x = (self.display_width - time_width) // 2 - time_y = 1 - self._draw_text_with_outline(draw, game_time, (time_x, time_y), self.fonts['time']) - - # Game date (Bottom center) - game_date = game.get("game_date", "") - if game_date: - date_width = draw.textlength(game_date, font=self.fonts['detail']) + self._draw_text_with_outline( + draw, time_text, (time_x, 1), self.fonts['time'] + ) + + if date_text: + date_font = self.fonts.get('detail') or self.fonts['time'] + date_width = draw.textlength(date_text, font=date_font) date_x = (self.display_width - date_width) // 2 - date_y = self.display_height - 7 - self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['detail']) - + # Measured, not a fixed -7: the detail font is 6px in most plugins + # but 10px in soccer and nrl, where "Sep 19" ran 5px past the card. + date_bottom = draw.textbbox((0, 0), date_text, font=date_font)[3] + date_y = max(0, self.display_height - date_bottom - 1) + self._draw_text_with_outline( + draw, date_text, (date_x, date_y), date_font + ) + def _draw_possession_indicator( self, draw: ImageDraw.Draw, diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 4002dab8..20a696a0 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "2.12.0", + "version": "2.13.0", "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", "description": "Standalone plugin for live, recent, and upcoming football games across NFL and NCAA Football with real-time scores, down/distance, possession, and game status. Now with organized nested config!", @@ -24,6 +24,12 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "2.13.0", + "released": "2026-08-06", + "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.12.0", "released": "2026-08-05", diff --git a/plugins/football-scoreboard/scroll_display.py b/plugins/football-scoreboard/scroll_display.py index 7de0ef56..737a8660 100644 --- a/plugins/football-scoreboard/scroll_display.py +++ b/plugins/football-scoreboard/scroll_display.py @@ -172,7 +172,7 @@ def prepare_scroll_content( # Get scroll settings scroll_settings = self._get_scroll_settings() - gap_between_games = scroll_settings.get("gap_between_games", 24) + gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) game_card_width = scroll_settings.get("game_card_width", 128) @@ -231,7 +231,10 @@ def prepare_scroll_content( # Add horizontal padding to prevent logos from being cut off at edges # Logos are positioned at -10 and display_width+10, so we need padding - padding = 12 # Padding on each side to ensure logos aren't cut off + # Half the gap each side, so adjacent cards are separated by exactly + # gap_between_games. Baking it into the card matters for Vegas, which + # stitches its own items and never sees the scroll helper item_gap. + padding = max(4, gap_between_games // 2) padded_width = game_img.width + (padding * 2) padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) padded_img.paste(game_img, (padding, 0)) @@ -253,7 +256,7 @@ def prepare_scroll_content( # Create scrolling image using ScrollHelper self.scroll_helper.create_scrolling_image( content_items, - item_gap=gap_between_games, + item_gap=0, # spacing already baked into each card element_gap=0 # No element gap - each item is a complete game card ) diff --git a/plugins/football-scoreboard/scroll_display_legacy.py b/plugins/football-scoreboard/scroll_display_legacy.py index afcf6cc8..e8e0bf77 100644 --- a/plugins/football-scoreboard/scroll_display_legacy.py +++ b/plugins/football-scoreboard/scroll_display_legacy.py @@ -313,7 +313,7 @@ def prepare_scroll_content( # Get scroll settings scroll_settings = self._get_scroll_settings() - gap_between_games = scroll_settings.get("gap_between_games", 24) + gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) game_card_width = scroll_settings.get("game_card_width", 128) @@ -372,7 +372,10 @@ def prepare_scroll_content( # Add horizontal padding to prevent logos from being cut off at edges # Logos are positioned at -10 and display_width+10, so we need padding - padding = 12 # Padding on each side to ensure logos aren't cut off + # Half the gap each side, so adjacent cards are separated by exactly + # gap_between_games. Baking it into the card matters for Vegas, which + # stitches its own items and never sees the scroll helper item_gap. + padding = max(4, gap_between_games // 2) padded_width = game_img.width + (padding * 2) padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) padded_img.paste(game_img, (padding, 0)) @@ -394,7 +397,7 @@ def prepare_scroll_content( # Create scrolling image using ScrollHelper self.scroll_helper.create_scrolling_image( content_items, - item_gap=gap_between_games, + item_gap=0, # spacing already baked into each card element_gap=0 # No element gap - each item is a complete game card ) diff --git a/plugins/hockey-scoreboard/config_schema.json b/plugins/hockey-scoreboard/config_schema.json index 352b2448..f1b17a06 100644 --- a/plugins/hockey-scoreboard/config_schema.json +++ b/plugins/hockey-scoreboard/config_schema.json @@ -4,6 +4,41 @@ "description": "Configuration schema for the Hockey Scoreboard plugin - displays live, recent, and upcoming NHL and NCAA Hockey games", "type": "object", "properties": { + "scroll_card": { + "type": "object", + "title": "Scroll & Vegas Card Layout", + "description": "Layout of the game cards built for scroll and Vegas modes. The full-screen scoreboard is drawn separately and is not affected by these settings.", + "x-advanced": true, + "properties": { + "upcoming_center": { + "type": "string", + "title": "Middle of an Upcoming Card", + "description": "What to show between the two logos before a game starts: VS, or the date and time stacked in the middle. Upcoming games never show a score, since the game has not been played.", + "enum": [ + "vs", + "date_time" + ], + "default": "vs" + }, + "date_format": { + "type": "string", + "title": "Date Format", + "description": "How to write the date on an upcoming card: abbrev shows \"Sep 19\", numeric shows \"9/19\".", + "enum": [ + "abbrev", + "numeric" + ], + "default": "abbrev" + }, + "center_gap": { + "type": "integer", + "title": "Center Gap", + "description": "Pixels kept clear down the middle of a card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", + "minimum": 0, + "maximum": 64 + } + } + }, "enabled": { "type": "boolean", "default": false, @@ -1297,14 +1332,6 @@ "title": "Display Customization", "description": "Customize fonts for different text elements on the scoreboard", "properties": { - "center_gap": { - "x-advanced": true, - "type": "integer", - "title": "Center Gap", - "description": "Pixels kept clear down the middle of a game card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", - "minimum": 0, - "maximum": 64 - }, "score_text": { "type": "object", "title": "Game Score", diff --git a/plugins/hockey-scoreboard/game_renderer.py b/plugins/hockey-scoreboard/game_renderer.py index 4d1c6d4c..4c5a2369 100644 --- a/plugins/hockey-scoreboard/game_renderer.py +++ b/plugins/hockey-scoreboard/game_renderer.py @@ -561,12 +561,7 @@ def render_game_card( fill=self._score_color_for(game, game_type) ) elif game_type == "upcoming": - # Draw "VS" for upcoming games - vs_text = "VS" - vs_width = draw_overlay.textlength(vs_text, font=self.fonts['score']) - vs_x = (self.display_width - vs_width) // 2 - vs_y = (self.display_height // 2) - 3 - self._draw_text_with_outline(draw_overlay, vs_text, (vs_x, vs_y), self.fonts['score']) + self._draw_upcoming_center(draw_overlay, game) # Draw period/status based on game type if game_type == "live": @@ -676,20 +671,37 @@ def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: self.logger.debug(f"Failed to parse start time '{raw_start}': {e}") return "", "" - # Middle strip reserved for the score / "VS", as a fraction of card width. - # 0.28 clears "1-2" (30px) on a 128px card with room to spare. + # ------------------------------------------------------------------ + # Scroll/Vegas card options -- config["scroll_card"]. + # + # These only affect the cards this renderer builds, which are used by + # scroll_display.py and scroll_display_legacy.py alone. The full-screen + # scorebug is drawn elsewhere and is deliberately left untouched. + # ------------------------------------------------------------------ + # Middle strip kept clear of logos so the score / "VS" is never drawn on + # top of them. 0.28 of a 128px card clears "1-2" (30px) with room spare. CENTER_GAP_RATIO: ClassVar[float] = 0.28 # 22 so "VS" (20px) still clears the logos on the narrowest 64px card. CENTER_GAP_MIN_PX: ClassVar[int] = 22 CENTER_GAP_MAX_PX: ClassVar[int] = 40 + _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ) + + def _scroll_card_option(self, key: str, default: Any = None) -> Any: + """Read one key from the scroll_card config block.""" + block = (self.config or {}).get("scroll_card") + if isinstance(block, dict) and block.get(key) is not None: + return block.get(key) + return default def _center_gap_width(self) -> int: """Width of the middle strip kept clear of logos. - ``customization.center_gap`` overrides it; 0 restores the old - edge-to-edge logos. + ``scroll_card.center_gap`` overrides it; 0 restores edge-to-edge logos. """ - configured = (self.config or {}).get('customization', {}).get('center_gap') + configured = self._scroll_card_option("center_gap") if isinstance(configured, (int, float)) and configured >= 0: return int(configured) scaled = round(self.display_width * self.CENTER_GAP_RATIO) @@ -698,13 +710,60 @@ def _center_gap_width(self) -> int: def _logo_slot_width(self) -> int: """Per-side logo slot, leaving the center gap clear. - Still capped at display_height, so wide/short cards (128x32, 256x32) - already have a large center gap and are left exactly as they were -- - only the sizes where the logos met in the middle (128x64, 64x32) shrink. + Capped at display_height, so wide/short cards (128x32, 256x32) already + have a large middle and come out unchanged -- only the sizes where the + logos used to meet (128x64, 64x32) shrink. """ available = (self.display_width - self._center_gap_width()) // 2 return max(8, min(self.display_height, available)) + def _upcoming_center_mode(self) -> str: + """What sits in the middle of an upcoming card: 'vs' or 'date_time'.""" + mode = str(self._scroll_card_option("upcoming_center", "vs") or "vs").lower() + return mode if mode in ("vs", "date_time") else "vs" + + def _format_game_date(self, date_text: str) -> str: + """Render a date as "Sep 19" (default) or "9/19" (``date_format``).""" + raw = str(date_text or "").strip() + if not raw or self._scroll_card_option("date_format", "abbrev") == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + if len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): + month = int(parts[0]) + if 1 <= month <= 12: + return f"{self._MONTH_ABBR[month - 1]} {int(parts[1])}" + return raw + + def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: + """Draw the middle of an upcoming card. + + Never a score: an upcoming game has not started, so the extractor's + 0-0 is noise. Either "VS" (default) or the date and time stacked. + """ + if self._upcoming_center_mode() == "vs": + vs_text = "VS" + vs_width = draw.textlength(vs_text, font=self.fonts['score']) + vs_x = (self.display_width - vs_width) // 2 + vs_y = (self.display_height // 2) - 3 + self._draw_text_with_outline( + draw, vs_text, (vs_x, vs_y), self.fonts['score'] + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + date_text = self._format_game_date(date_text) + font = self.fonts.get('detail') or self.fonts['time'] + lines = [t for t in (date_text, time_text) if t] + if not lines: + return + line_h = 7 + top = (self.display_height // 2) - (len(lines) * line_h) // 2 + for i, line in enumerate(lines): + width = draw.textlength(line, font=font) + self._draw_text_with_outline( + draw, line, ((self.display_width - width) // 2, top + i * line_h), font + ) + @staticmethod def _compact_time(text: str) -> str: """Trim "7:00 PM EDT" to "7:00PM" so it fits a 64px-wide half-card.""" @@ -729,13 +788,16 @@ def _display_tzinfo(self): return timezone.utc def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: - """Draw date/time for an upcoming hockey game. + """Draw date/time around an upcoming card: time top, date bottom. - Matches the other sports' scroll cards: time top-center, date - bottom-center, so the two logos are never left touching with a bare - "VS" between them. + Skipped when the date and time are stacked in the middle instead -- + drawing both would print them twice. """ + if self._upcoming_center_mode() != "vs": + return + date_text, time_text = self._upcoming_date_and_time(game) + date_text = self._format_game_date(date_text) if time_text: time_width = draw.textlength(time_text, font=self.fonts['time']) @@ -748,7 +810,10 @@ def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: date_font = self.fonts.get('detail') or self.fonts['time'] date_width = draw.textlength(date_text, font=date_font) date_x = (self.display_width - date_width) // 2 - date_y = self.display_height - 7 + # Measured, not a fixed -7: the detail font is 6px in most plugins + # but 10px in soccer and nrl, where "Sep 19" ran 5px past the card. + date_bottom = draw.textbbox((0, 0), date_text, font=date_font)[3] + date_y = max(0, self.display_height - date_bottom - 1) self._draw_text_with_outline( draw, date_text, (date_x, date_y), date_font ) diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 80f44f5e..e85cc3bb 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -57,7 +57,7 @@ { "version": "1.9.0", "released": "2026-08-06", - "notes": "Fix missing text and cramped spacing on scroll and Vegas game cards. Upcoming games showed a bare \"VS\" with no date or time: the card read status.short_detail and start_time, but the scroll path is fed by the sports extractor, which emits game_date, game_time and start_time_utc instead, so both lookups came up empty. Live games showed the period without the game clock (\"P2\" rather than \"P2 12:34\") because the payload normalizer wrote status.clock while the card reads the canonical status.display_clock. Date and time now render top- and bottom-center to match the other sports, and the live clock renders again. The card also reserves a gap down the middle so the score or VS is no longer drawn on top of the team logos -- tune it with customization.center_gap, or set 0 for the previous edge-to-edge logos. Cards that already had a wide centre (128x32, 256x32) are unchanged. Finally, gap_between_games is now honoured in Vegas mode: the spacing is baked into each card rather than applied only by the scroll helper, and the code default moves from 24 to 48 to match the value the config schema has always advertised.", + "notes": "Fix missing text and cramped spacing on scroll and Vegas game cards. Upcoming games showed a bare \"VS\" with no date or time: the card read status.short_detail and start_time, but the scroll path is fed by the sports extractor, which emits game_date, game_time and start_time_utc instead, so both lookups came up empty. Live games showed the period without the game clock (\"P2\" rather than \"P2 12:34\") because the payload normalizer wrote status.clock while the card reads the canonical status.display_clock. Date and time now render top- and bottom-center to match the other sports, and the live clock renders again. Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged.", "ledmatrix_min_version": "2.0.0" }, { diff --git a/plugins/hockey-scoreboard/scroll_display_legacy.py b/plugins/hockey-scoreboard/scroll_display_legacy.py index 5aaf25ab..4fc52510 100644 --- a/plugins/hockey-scoreboard/scroll_display_legacy.py +++ b/plugins/hockey-scoreboard/scroll_display_legacy.py @@ -336,7 +336,7 @@ def prepare_scroll_content( # Get scroll settings using primary league from the provided leagues list primary_league = leagues[0] if leagues else None scroll_settings = self._get_scroll_settings(primary_league) - gap_between_games = scroll_settings.get("gap_between_games", 24) + gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) game_card_width = scroll_settings.get("game_card_width", 128) @@ -399,7 +399,10 @@ def prepare_scroll_content( # Add horizontal padding to prevent logos from being cut off at edges # Logos are positioned at -10 and display_width+10, so we need padding - padding = 12 # Padding on each side to ensure logos aren't cut off + # Half the gap each side, so adjacent cards are separated by exactly + # gap_between_games. Baking it into the card matters for Vegas, which + # stitches its own items and never sees the scroll helper item_gap. + padding = max(4, gap_between_games // 2) padded_width = game_img.width + (padding * 2) padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) padded_img.paste(game_img, (padding, 0)) @@ -421,7 +424,7 @@ def prepare_scroll_content( # Create scrolling image using ScrollHelper self.scroll_helper.create_scrolling_image( content_items, - item_gap=gap_between_games, + item_gap=0, # spacing already baked into each card element_gap=0 # No element gap - each item is a complete game card ) diff --git a/plugins/lacrosse-scoreboard/config_schema.json b/plugins/lacrosse-scoreboard/config_schema.json index 479d230c..f1e30850 100644 --- a/plugins/lacrosse-scoreboard/config_schema.json +++ b/plugins/lacrosse-scoreboard/config_schema.json @@ -4,6 +4,41 @@ "description": "Configuration schema for the Lacrosse Scoreboard plugin (NCAA Men's and Women's Lacrosse)", "type": "object", "properties": { + "scroll_card": { + "type": "object", + "title": "Scroll & Vegas Card Layout", + "description": "Layout of the game cards built for scroll and Vegas modes. The full-screen scoreboard is drawn separately and is not affected by these settings.", + "x-advanced": true, + "properties": { + "upcoming_center": { + "type": "string", + "title": "Middle of an Upcoming Card", + "description": "What to show between the two logos before a game starts: VS, or the date and time stacked in the middle. Upcoming games never show a score, since the game has not been played.", + "enum": [ + "vs", + "date_time" + ], + "default": "vs" + }, + "date_format": { + "type": "string", + "title": "Date Format", + "description": "How to write the date on an upcoming card: abbrev shows \"Sep 19\", numeric shows \"9/19\".", + "enum": [ + "abbrev", + "numeric" + ], + "default": "abbrev" + }, + "center_gap": { + "type": "integer", + "title": "Center Gap", + "description": "Pixels kept clear down the middle of a card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", + "minimum": 0, + "maximum": 64 + } + } + }, "enabled": { "type": "boolean", "default": false, diff --git a/plugins/lacrosse-scoreboard/game_renderer.py b/plugins/lacrosse-scoreboard/game_renderer.py index 9b66b7ad..507d94e9 100644 --- a/plugins/lacrosse-scoreboard/game_renderer.py +++ b/plugins/lacrosse-scoreboard/game_renderer.py @@ -194,11 +194,11 @@ def _load_and_resize_logo( """Load and resize a team logo with caching.""" cache_key = f"{league}_{team_abbrev}" if cache_key in self._logo_cache: - return self._logo_cache[cache_key] + return self._logo_cache[self._logo_cache_key(cache_key)] # Also check without league prefix for backward compatibility if team_abbrev in self._logo_cache: - return self._logo_cache[team_abbrev] + return self._logo_cache[self._logo_cache_key(team_abbrev)] try: # Use provided path or get from league config @@ -220,9 +220,9 @@ def _load_and_resize_logo( bbox = logo.getbbox() if bbox: logo = logo.crop(bbox) - logo.thumbnail((self.display_height, self.display_height), RESAMPLE_FILTER) + logo.thumbnail((self._logo_slot_width(), self.display_height), RESAMPLE_FILTER) - self._logo_cache[cache_key] = logo + self._logo_cache[self._logo_cache_key(cache_key)] = logo return logo else: self.logger.debug(f"Logo not found at {logo_path}") @@ -515,7 +515,7 @@ def render_game_card( # Draw logos — each centered within a slot on its side; cap at half the card # width so home_slot_start stays non-negative on square/tall displays - logo_slot = min(self.display_height, self.display_width // 2) + logo_slot = self._logo_slot_width() away_x = (logo_slot - away_logo.width) // 2 away_y = center_y - (away_logo.height // 2) main_img.paste(away_logo, (away_x, away_y), away_logo) @@ -619,32 +619,144 @@ def _draw_recent_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: status_y = 1 self._draw_text_with_outline(draw, status_text, (status_x, status_y), self.fonts['time']) - def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: - """Draw status elements for an upcoming game. + # ------------------------------------------------------------------ + # Scroll/Vegas card options -- config["scroll_card"]. + # + # These only affect the cards this renderer builds, which are used by + # scroll_display.py and scroll_display_legacy.py alone. The full-screen + # scorebug is drawn elsewhere and is deliberately left untouched. + # ------------------------------------------------------------------ + # Middle strip kept clear of logos so the score / "VS" is never drawn on + # top of them. 0.28 of a 128px card clears "1-2" (30px) with room spare. + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + # 22 so "VS" (20px) still clears the logos on the narrowest 64px card. + CENTER_GAP_MIN_PX: ClassVar[int] = 22 + CENTER_GAP_MAX_PX: ClassVar[int] = 40 + _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ) + + def _logo_cache_key(self, name: str) -> str: + """Cache key scoped to the logo slot. + + One cache dict is shared by renderers built for different card widths, + so a logo sized for a wide slot must not be handed to a narrow one. + """ + return f"{name}@{self._logo_slot_width()}x{self.display_height}" + + def _scroll_card_option(self, key: str, default: Any = None) -> Any: + """Read one key from the scroll_card config block.""" + block = (self.config or {}).get("scroll_card") + if isinstance(block, dict) and block.get(key) is not None: + return block.get(key) + return default - Matches the direct display path: "Next Game" label at top, then stacked - date and time centered on the card — no "VS" text. + def _center_gap_width(self) -> int: + """Width of the middle strip kept clear of logos. + + ``scroll_card.center_gap`` overrides it; 0 restores edge-to-edge logos. + """ + configured = self._scroll_card_option("center_gap") + if isinstance(configured, (int, float)) and configured >= 0: + return int(configured) + scaled = round(self.display_width * self.CENTER_GAP_RATIO) + return int(max(self.CENTER_GAP_MIN_PX, min(self.CENTER_GAP_MAX_PX, scaled))) + + def _logo_slot_width(self) -> int: + """Per-side logo slot, leaving the center gap clear. + + Capped at display_height, so wide/short cards (128x32, 256x32) already + have a large middle and come out unchanged -- only the sizes where the + logos used to meet (128x64, 64x32) shrink. + """ + available = (self.display_width - self._center_gap_width()) // 2 + return max(8, min(self.display_height, available)) + + def _upcoming_center_mode(self) -> str: + """What sits in the middle of an upcoming card: 'vs' or 'date_time'.""" + mode = str(self._scroll_card_option("upcoming_center", "vs") or "vs").lower() + return mode if mode in ("vs", "date_time") else "vs" + + def _format_game_date(self, date_text: str) -> str: + """Render a date as "Sep 19" (default) or "9/19" (``date_format``).""" + raw = str(date_text or "").strip() + if not raw or self._scroll_card_option("date_format", "abbrev") == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + if len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): + month = int(parts[0]) + if 1 <= month <= 12: + return f"{self._MONTH_ABBR[month - 1]} {int(parts[1])}" + return raw + + def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: + """Draw the middle of an upcoming card. + + Never a score: an upcoming game has not started, so the extractor's + 0-0 is noise. Either "VS" (default) or the date and time stacked. """ - game_date = game.get("game_date", "") - game_time = game.get("game_time", "") + if self._upcoming_center_mode() == "vs": + vs_text = "VS" + vs_width = draw.textlength(vs_text, font=self.fonts['score']) + vs_x = (self.display_width - vs_width) // 2 + vs_y = (self.display_height // 2) - 3 + self._draw_text_with_outline( + draw, vs_text, (vs_x, vs_y), self.fonts['score'] + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + date_text = self._format_game_date(date_text) + font = self.fonts.get('detail') or self.fonts['time'] + lines = [t for t in (date_text, time_text) if t] + if not lines: + return + line_h = 7 + top = (self.display_height // 2) - (len(lines) * line_h) // 2 + for i, line in enumerate(lines): + width = draw.textlength(line, font=font) + self._draw_text_with_outline( + draw, line, ((self.display_width - width) // 2, top + i * line_h), font + ) - # "Next Game" label at top — smaller font on narrow displays - status_font = self.fonts['status'] if self.display_width <= 128 else self.fonts['time'] - label = "Next Game" - label_w = draw.textlength(label, font=status_font) - self._draw_text_with_outline(draw, label, ((self.display_width - label_w) // 2, 1), status_font) + def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: + """(date, time) for an upcoming card, from the extractor's flat keys.""" + return ( + str(game.get("game_date", "") or ""), + str(game.get("game_time", "") or ""), + ) - # Stacked date / time centered vertically - center_y = self.display_height // 2 - date_y = center_y - 7 + def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: + """Draw date/time around an upcoming card: time top, date bottom. + + Skipped when the date and time are stacked in the middle instead -- + drawing both would print them twice. + """ + if self._upcoming_center_mode() != "vs": + return - if game_date: - date_w = draw.textlength(game_date, font=self.fonts['time']) - self._draw_text_with_outline(draw, game_date, ((self.display_width - date_w) // 2, date_y), self.fonts['time']) + date_text, time_text = self._upcoming_date_and_time(game) + date_text = self._format_game_date(date_text) - if game_time: - time_w = draw.textlength(game_time, font=self.fonts['time']) - self._draw_text_with_outline(draw, game_time, ((self.display_width - time_w) // 2, date_y + 9), self.fonts['time']) + if time_text: + time_width = draw.textlength(time_text, font=self.fonts['time']) + time_x = (self.display_width - time_width) // 2 + self._draw_text_with_outline( + draw, time_text, (time_x, 1), self.fonts['time'] + ) + + if date_text: + date_font = self.fonts.get('detail') or self.fonts['time'] + date_width = draw.textlength(date_text, font=date_font) + date_x = (self.display_width - date_width) // 2 + # Measured, not a fixed -7: the detail font is 6px in most plugins + # but 10px in soccer and nrl, where "Sep 19" ran 5px past the card. + date_bottom = draw.textbbox((0, 0), date_text, font=date_font)[3] + date_y = max(0, self.display_height - date_bottom - 1) + self._draw_text_with_outline( + draw, date_text, (date_x, date_y), date_font + ) def _draw_dynamic_odds( self, diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index 76260a2b..06899c64 100644 --- a/plugins/lacrosse-scoreboard/manifest.json +++ b/plugins/lacrosse-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "lacrosse-scoreboard", "name": "Lacrosse Scoreboard", - "version": "1.8.0", + "version": "1.9.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard", @@ -50,6 +50,12 @@ } ], "versions": [ + { + "version": "1.9.0", + "released": "2026-08-06", + "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.8.0", "released": "2026-08-05", diff --git a/plugins/lacrosse-scoreboard/scroll_display.py b/plugins/lacrosse-scoreboard/scroll_display.py index 985d8e62..dd36bdcd 100644 --- a/plugins/lacrosse-scoreboard/scroll_display.py +++ b/plugins/lacrosse-scoreboard/scroll_display.py @@ -192,7 +192,7 @@ def prepare_scroll_content( # Get scroll settings using primary league from the provided leagues list primary_league = leagues[0] if leagues else None scroll_settings = self._get_scroll_settings(primary_league) - gap_between_games = scroll_settings.get("gap_between_games", 24) + gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) game_card_width = scroll_settings.get("game_card_width", 128) @@ -255,7 +255,10 @@ def prepare_scroll_content( # Add horizontal padding to prevent logos from being cut off at edges # Logos are positioned at -10 and display_width+10, so we need padding - padding = 12 # Padding on each side to ensure logos aren't cut off + # Half the gap each side, so adjacent cards are separated by exactly + # gap_between_games. Baking it into the card matters for Vegas, which + # stitches its own items and never sees the scroll helper item_gap. + padding = max(4, gap_between_games // 2) padded_width = game_img.width + (padding * 2) padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) padded_img.paste(game_img, (padding, 0)) @@ -277,7 +280,7 @@ def prepare_scroll_content( # Create scrolling image using ScrollHelper self.scroll_helper.create_scrolling_image( content_items, - item_gap=gap_between_games, + item_gap=0, # spacing already baked into each card element_gap=0 # No element gap - each item is a complete game card ) diff --git a/plugins/lacrosse-scoreboard/scroll_display_legacy.py b/plugins/lacrosse-scoreboard/scroll_display_legacy.py index caeae4ad..b7083ad9 100644 --- a/plugins/lacrosse-scoreboard/scroll_display_legacy.py +++ b/plugins/lacrosse-scoreboard/scroll_display_legacy.py @@ -317,7 +317,7 @@ def prepare_scroll_content( # Get scroll settings using primary league from the provided leagues list primary_league = leagues[0] if leagues else None scroll_settings = self._get_scroll_settings(primary_league) - gap_between_games = scroll_settings.get("gap_between_games", 24) + gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) game_card_width = scroll_settings.get("game_card_width", 128) @@ -380,7 +380,10 @@ def prepare_scroll_content( # Add horizontal padding to prevent logos from being cut off at edges # Logos are positioned at -10 and display_width+10, so we need padding - padding = 12 # Padding on each side to ensure logos aren't cut off + # Half the gap each side, so adjacent cards are separated by exactly + # gap_between_games. Baking it into the card matters for Vegas, which + # stitches its own items and never sees the scroll helper item_gap. + padding = max(4, gap_between_games // 2) padded_width = game_img.width + (padding * 2) padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) padded_img.paste(game_img, (padding, 0)) @@ -402,7 +405,7 @@ def prepare_scroll_content( # Create scrolling image using ScrollHelper self.scroll_helper.create_scrolling_image( content_items, - item_gap=gap_between_games, + item_gap=0, # spacing already baked into each card element_gap=0 # No element gap - each item is a complete game card ) diff --git a/plugins/nrl-scoreboard/config_schema.json b/plugins/nrl-scoreboard/config_schema.json index 386a6a7d..9f2d7f99 100644 --- a/plugins/nrl-scoreboard/config_schema.json +++ b/plugins/nrl-scoreboard/config_schema.json @@ -4,6 +4,41 @@ "description": "Settings for the NRL (National Rugby League) scoreboard: live, recent, and upcoming games from ESPN.", "type": "object", "properties": { + "scroll_card": { + "type": "object", + "title": "Scroll & Vegas Card Layout", + "description": "Layout of the game cards built for scroll and Vegas modes. The full-screen scoreboard is drawn separately and is not affected by these settings.", + "x-advanced": true, + "properties": { + "upcoming_center": { + "type": "string", + "title": "Middle of an Upcoming Card", + "description": "What to show between the two logos before a game starts: VS, or the date and time stacked in the middle. Upcoming games never show a score, since the game has not been played.", + "enum": [ + "vs", + "date_time" + ], + "default": "vs" + }, + "date_format": { + "type": "string", + "title": "Date Format", + "description": "How to write the date on an upcoming card: abbrev shows \"Sep 19\", numeric shows \"9/19\".", + "enum": [ + "abbrev", + "numeric" + ], + "default": "abbrev" + }, + "center_gap": { + "type": "integer", + "title": "Center Gap", + "description": "Pixels kept clear down the middle of a card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", + "minimum": 0, + "maximum": 64 + } + } + }, "enabled": { "type": "boolean", "default": true, diff --git a/plugins/nrl-scoreboard/game_renderer.py b/plugins/nrl-scoreboard/game_renderer.py index 0bb85d9a..b68d8f4c 100644 --- a/plugins/nrl-scoreboard/game_renderer.py +++ b/plugins/nrl-scoreboard/game_renderer.py @@ -158,7 +158,7 @@ def preload_logos(self, games: list, logo_dir: Path) -> None: game.get(f'{team_key.replace("abbr", "logo_url")}') ) if logo: - self._logo_cache[abbr] = logo + self._logo_cache[self._logo_cache_key(abbr)] = logo self.logger.debug(f"Preloaded {len(self._logo_cache)} team logos") @@ -171,7 +171,7 @@ def _load_and_resize_logo( ) -> Optional[Image.Image]: """Load and resize a team logo with caching.""" if team_abbrev in self._logo_cache: - return self._logo_cache[team_abbrev] + return self._logo_cache[self._logo_cache_key(team_abbrev)] try: # Try to load from path @@ -186,9 +186,9 @@ def _load_and_resize_logo( bbox = logo.getbbox() if bbox: logo = logo.crop(bbox) - logo.thumbnail((self.display_height, self.display_height), Image.Resampling.LANCZOS) + logo.thumbnail((self._logo_slot_width(), self.display_height), Image.Resampling.LANCZOS) - self._logo_cache[team_abbrev] = logo + self._logo_cache[self._logo_cache_key(team_abbrev)] = logo return logo else: self.logger.debug(f"Logo not found at {logo_path}") @@ -470,7 +470,7 @@ def render_game_card( # Place logos — each centered within a slot on its side; cap at half the card # width so home_slot_start stays non-negative on square/tall displays - logo_slot = min(self.display_height, self.display_width // 2) + logo_slot = self._logo_slot_width() away_x = (logo_slot - away_logo.width) // 2 away_y = center_y - (away_logo.height // 2) @@ -482,13 +482,17 @@ def render_game_card( main_img.paste(home_logo, (home_x, home_y), home_logo) main_img.paste(away_logo, (away_x, away_y), away_logo) - # Draw scores (centered) - score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 - self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts['score'], - fill=self._score_color_for(game, game_type) - ) + # Draw scores (centered) — only once a game has started. Upcoming games + # have no score, so the extractor's 0-0 was pure noise. + if game_type in ("live", "recent"): + score_x = (self.display_width - score_width) // 2 + score_y = (self.display_height // 2) - 3 + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts['score'], + fill=self._score_color_for(game, game_type) + ) + elif game_type == "upcoming": + self._draw_upcoming_center(draw_overlay, game) # Draw period/status based on game type if game_type == "live": @@ -556,24 +560,145 @@ def _draw_recent_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: date_y = self.display_height - 7 self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['detail']) + # ------------------------------------------------------------------ + # Scroll/Vegas card options -- config["scroll_card"]. + # + # These only affect the cards this renderer builds, which are used by + # scroll_display.py and scroll_display_legacy.py alone. The full-screen + # scorebug is drawn elsewhere and is deliberately left untouched. + # ------------------------------------------------------------------ + # Middle strip kept clear of logos so the score / "VS" is never drawn on + # top of them. 0.28 of a 128px card clears "1-2" (30px) with room spare. + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + # 22 so "VS" (20px) still clears the logos on the narrowest 64px card. + CENTER_GAP_MIN_PX: ClassVar[int] = 22 + CENTER_GAP_MAX_PX: ClassVar[int] = 40 + _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ) + + def _logo_cache_key(self, name: str) -> str: + """Cache key scoped to the logo slot. + + One cache dict is shared by renderers built for different card widths, + so a logo sized for a wide slot must not be handed to a narrow one. + """ + return f"{name}@{self._logo_slot_width()}x{self.display_height}" + + def _scroll_card_option(self, key: str, default: Any = None) -> Any: + """Read one key from the scroll_card config block.""" + block = (self.config or {}).get("scroll_card") + if isinstance(block, dict) and block.get(key) is not None: + return block.get(key) + return default + + def _center_gap_width(self) -> int: + """Width of the middle strip kept clear of logos. + + ``scroll_card.center_gap`` overrides it; 0 restores edge-to-edge logos. + """ + configured = self._scroll_card_option("center_gap") + if isinstance(configured, (int, float)) and configured >= 0: + return int(configured) + scaled = round(self.display_width * self.CENTER_GAP_RATIO) + return int(max(self.CENTER_GAP_MIN_PX, min(self.CENTER_GAP_MAX_PX, scaled))) + + def _logo_slot_width(self) -> int: + """Per-side logo slot, leaving the center gap clear. + + Capped at display_height, so wide/short cards (128x32, 256x32) already + have a large middle and come out unchanged -- only the sizes where the + logos used to meet (128x64, 64x32) shrink. + """ + available = (self.display_width - self._center_gap_width()) // 2 + return max(8, min(self.display_height, available)) + + def _upcoming_center_mode(self) -> str: + """What sits in the middle of an upcoming card: 'vs' or 'date_time'.""" + mode = str(self._scroll_card_option("upcoming_center", "vs") or "vs").lower() + return mode if mode in ("vs", "date_time") else "vs" + + def _format_game_date(self, date_text: str) -> str: + """Render a date as "Sep 19" (default) or "9/19" (``date_format``).""" + raw = str(date_text or "").strip() + if not raw or self._scroll_card_option("date_format", "abbrev") == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + if len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): + month = int(parts[0]) + if 1 <= month <= 12: + return f"{self._MONTH_ABBR[month - 1]} {int(parts[1])}" + return raw + + def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: + """Draw the middle of an upcoming card. + + Never a score: an upcoming game has not started, so the extractor's + 0-0 is noise. Either "VS" (default) or the date and time stacked. + """ + if self._upcoming_center_mode() == "vs": + vs_text = "VS" + vs_width = draw.textlength(vs_text, font=self.fonts['score']) + vs_x = (self.display_width - vs_width) // 2 + vs_y = (self.display_height // 2) - 3 + self._draw_text_with_outline( + draw, vs_text, (vs_x, vs_y), self.fonts['score'] + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + date_text = self._format_game_date(date_text) + font = self.fonts.get('detail') or self.fonts['time'] + lines = [t for t in (date_text, time_text) if t] + if not lines: + return + line_h = 7 + top = (self.display_height // 2) - (len(lines) * line_h) // 2 + for i, line in enumerate(lines): + width = draw.textlength(line, font=font) + self._draw_text_with_outline( + draw, line, ((self.display_width - width) // 2, top + i * line_h), font + ) + + def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: + """(date, time) for an upcoming card, from the extractor's flat keys.""" + return ( + str(game.get("game_date", "") or ""), + str(game.get("game_time", "") or ""), + ) + def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: - """Draw status elements for an upcoming NRL game.""" - # Game time (Top center) - game_time = game.get("game_time", "") - if game_time: - time_width = draw.textlength(game_time, font=self.fonts['time']) + """Draw date/time around an upcoming card: time top, date bottom. + + Skipped when the date and time are stacked in the middle instead -- + drawing both would print them twice. + """ + if self._upcoming_center_mode() != "vs": + return + + date_text, time_text = self._upcoming_date_and_time(game) + date_text = self._format_game_date(date_text) + + if time_text: + time_width = draw.textlength(time_text, font=self.fonts['time']) time_x = (self.display_width - time_width) // 2 - time_y = 1 - self._draw_text_with_outline(draw, game_time, (time_x, time_y), self.fonts['time']) - - # Game date (Bottom center) - game_date = game.get("game_date", "") - if game_date: - date_width = draw.textlength(game_date, font=self.fonts['detail']) + self._draw_text_with_outline( + draw, time_text, (time_x, 1), self.fonts['time'] + ) + + if date_text: + date_font = self.fonts.get('detail') or self.fonts['time'] + date_width = draw.textlength(date_text, font=date_font) date_x = (self.display_width - date_width) // 2 - date_y = self.display_height - 7 - self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['detail']) - + # Measured, not a fixed -7: the detail font is 6px in most plugins + # but 10px in soccer and nrl, where "Sep 19" ran 5px past the card. + date_bottom = draw.textbbox((0, 0), date_text, font=date_font)[3] + date_y = max(0, self.display_height - date_bottom - 1) + self._draw_text_with_outline( + draw, date_text, (date_x, date_y), date_font + ) + def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None: """Draw odds with dynamic positioning.""" try: diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 0d3a046f..946b8f1c 100644 --- a/plugins/nrl-scoreboard/manifest.json +++ b/plugins/nrl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "nrl-scoreboard", "name": "NRL Scoreboard", - "version": "1.4.0", + "version": "1.5.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "nrl_upcoming" ], "versions": [ + { + "version": "1.5.0", + "released": "2026-08-06", + "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.4.0", "released": "2026-08-05", diff --git a/plugins/nrl-scoreboard/scroll_display.py b/plugins/nrl-scoreboard/scroll_display.py index 3f3390ba..8ddb8226 100644 --- a/plugins/nrl-scoreboard/scroll_display.py +++ b/plugins/nrl-scoreboard/scroll_display.py @@ -255,7 +255,7 @@ def prepare_scroll_content( # Get scroll settings scroll_settings = self._get_scroll_settings() - gap_between_games = scroll_settings.get("gap_between_games", 24) + gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) game_card_width = scroll_settings.get("game_card_width", 128) @@ -314,7 +314,10 @@ def prepare_scroll_content( # Add horizontal padding to prevent logos from being cut off at edges # Logos are positioned at -10 and display_width+10, so we need padding - padding = 12 # Padding on each side to ensure logos aren't cut off + # Half the gap each side, so adjacent cards are separated by exactly + # gap_between_games. Baking it into the card matters for Vegas, which + # stitches its own items and never sees the scroll helper item_gap. + padding = max(4, gap_between_games // 2) padded_width = game_img.width + (padding * 2) padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) padded_img.paste(game_img, (padding, 0)) @@ -336,7 +339,7 @@ def prepare_scroll_content( # Create scrolling image using ScrollHelper self.scroll_helper.create_scrolling_image( content_items, - item_gap=gap_between_games, + item_gap=0, # spacing already baked into each card element_gap=0 # No element gap - each item is a complete game card ) diff --git a/plugins/nrl-scoreboard/scroll_display_legacy.py b/plugins/nrl-scoreboard/scroll_display_legacy.py index e734850e..d7dce6c5 100644 --- a/plugins/nrl-scoreboard/scroll_display_legacy.py +++ b/plugins/nrl-scoreboard/scroll_display_legacy.py @@ -342,7 +342,7 @@ def prepare_scroll_content( # Get scroll settings scroll_settings = self._get_scroll_settings() - gap_between_games = scroll_settings.get("gap_between_games", 24) + gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) game_card_width = scroll_settings.get("game_card_width", 128) @@ -401,7 +401,10 @@ def prepare_scroll_content( # Add horizontal padding to prevent logos from being cut off at edges # Logos are positioned at -10 and display_width+10, so we need padding - padding = 12 # Padding on each side to ensure logos aren't cut off + # Half the gap each side, so adjacent cards are separated by exactly + # gap_between_games. Baking it into the card matters for Vegas, which + # stitches its own items and never sees the scroll helper item_gap. + padding = max(4, gap_between_games // 2) padded_width = game_img.width + (padding * 2) padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) padded_img.paste(game_img, (padding, 0)) @@ -423,7 +426,7 @@ def prepare_scroll_content( # Create scrolling image using ScrollHelper self.scroll_helper.create_scrolling_image( content_items, - item_gap=gap_between_games, + item_gap=0, # spacing already baked into each card element_gap=0 # No element gap - each item is a complete game card ) diff --git a/plugins/soccer-scoreboard/config_schema.json b/plugins/soccer-scoreboard/config_schema.json index b36b9676..92576f2f 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -4,6 +4,41 @@ "description": "Configuration schema for the Soccer Scoreboard plugin", "type": "object", "properties": { + "scroll_card": { + "type": "object", + "title": "Scroll & Vegas Card Layout", + "description": "Layout of the game cards built for scroll and Vegas modes. The full-screen scoreboard is drawn separately and is not affected by these settings.", + "x-advanced": true, + "properties": { + "upcoming_center": { + "type": "string", + "title": "Middle of an Upcoming Card", + "description": "What to show between the two logos before a game starts: VS, or the date and time stacked in the middle. Upcoming games never show a score, since the game has not been played.", + "enum": [ + "vs", + "date_time" + ], + "default": "vs" + }, + "date_format": { + "type": "string", + "title": "Date Format", + "description": "How to write the date on an upcoming card: abbrev shows \"Sep 19\", numeric shows \"9/19\".", + "enum": [ + "abbrev", + "numeric" + ], + "default": "abbrev" + }, + "center_gap": { + "type": "integer", + "title": "Center Gap", + "description": "Pixels kept clear down the middle of a card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", + "minimum": 0, + "maximum": 64 + } + } + }, "enabled": { "type": "boolean", "default": false, diff --git a/plugins/soccer-scoreboard/game_renderer.py b/plugins/soccer-scoreboard/game_renderer.py index eebf4857..657ab131 100644 --- a/plugins/soccer-scoreboard/game_renderer.py +++ b/plugins/soccer-scoreboard/game_renderer.py @@ -158,7 +158,7 @@ def preload_logos(self, games: list, logo_dir: Path) -> None: game.get(f'{team_key.replace("abbr", "logo_url")}') ) if logo: - self._logo_cache[abbr] = logo + self._logo_cache[self._logo_cache_key(abbr)] = logo self.logger.debug(f"Preloaded {len(self._logo_cache)} team logos") @@ -171,7 +171,7 @@ def _load_and_resize_logo( ) -> Optional[Image.Image]: """Load and resize a team logo with caching.""" if team_abbrev in self._logo_cache: - return self._logo_cache[team_abbrev] + return self._logo_cache[self._logo_cache_key(team_abbrev)] try: # Try to load from path @@ -186,9 +186,9 @@ def _load_and_resize_logo( bbox = logo.getbbox() if bbox: logo = logo.crop(bbox) - logo.thumbnail((self.display_height, self.display_height), Image.Resampling.LANCZOS) + logo.thumbnail((self._logo_slot_width(), self.display_height), Image.Resampling.LANCZOS) - self._logo_cache[team_abbrev] = logo + self._logo_cache[self._logo_cache_key(team_abbrev)] = logo return logo else: self.logger.debug(f"Logo not found at {logo_path}") @@ -470,7 +470,7 @@ def render_game_card( # Place logos — each centered within a slot on its side; cap at half the card # width so home_slot_start stays non-negative on square/tall displays - logo_slot = min(self.display_height, self.display_width // 2) + logo_slot = self._logo_slot_width() away_x = (logo_slot - away_logo.width) // 2 away_y = center_y - (away_logo.height // 2) @@ -482,13 +482,17 @@ def render_game_card( main_img.paste(home_logo, (home_x, home_y), home_logo) main_img.paste(away_logo, (away_x, away_y), away_logo) - # Draw scores (centered) - score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 - self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts['score'], - fill=self._score_color_for(game, game_type) - ) + # Draw scores (centered) — only once a game has started. Upcoming games + # have no score, so the extractor's 0-0 was pure noise. + if game_type in ("live", "recent"): + score_x = (self.display_width - score_width) // 2 + score_y = (self.display_height // 2) - 3 + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts['score'], + fill=self._score_color_for(game, game_type) + ) + elif game_type == "upcoming": + self._draw_upcoming_center(draw_overlay, game) # Draw period/status based on game type if game_type == "live": @@ -556,24 +560,145 @@ def _draw_recent_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: date_y = self.display_height - 7 self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['detail']) + # ------------------------------------------------------------------ + # Scroll/Vegas card options -- config["scroll_card"]. + # + # These only affect the cards this renderer builds, which are used by + # scroll_display.py and scroll_display_legacy.py alone. The full-screen + # scorebug is drawn elsewhere and is deliberately left untouched. + # ------------------------------------------------------------------ + # Middle strip kept clear of logos so the score / "VS" is never drawn on + # top of them. 0.28 of a 128px card clears "1-2" (30px) with room spare. + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + # 22 so "VS" (20px) still clears the logos on the narrowest 64px card. + CENTER_GAP_MIN_PX: ClassVar[int] = 22 + CENTER_GAP_MAX_PX: ClassVar[int] = 40 + _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ) + + def _logo_cache_key(self, name: str) -> str: + """Cache key scoped to the logo slot. + + One cache dict is shared by renderers built for different card widths, + so a logo sized for a wide slot must not be handed to a narrow one. + """ + return f"{name}@{self._logo_slot_width()}x{self.display_height}" + + def _scroll_card_option(self, key: str, default: Any = None) -> Any: + """Read one key from the scroll_card config block.""" + block = (self.config or {}).get("scroll_card") + if isinstance(block, dict) and block.get(key) is not None: + return block.get(key) + return default + + def _center_gap_width(self) -> int: + """Width of the middle strip kept clear of logos. + + ``scroll_card.center_gap`` overrides it; 0 restores edge-to-edge logos. + """ + configured = self._scroll_card_option("center_gap") + if isinstance(configured, (int, float)) and configured >= 0: + return int(configured) + scaled = round(self.display_width * self.CENTER_GAP_RATIO) + return int(max(self.CENTER_GAP_MIN_PX, min(self.CENTER_GAP_MAX_PX, scaled))) + + def _logo_slot_width(self) -> int: + """Per-side logo slot, leaving the center gap clear. + + Capped at display_height, so wide/short cards (128x32, 256x32) already + have a large middle and come out unchanged -- only the sizes where the + logos used to meet (128x64, 64x32) shrink. + """ + available = (self.display_width - self._center_gap_width()) // 2 + return max(8, min(self.display_height, available)) + + def _upcoming_center_mode(self) -> str: + """What sits in the middle of an upcoming card: 'vs' or 'date_time'.""" + mode = str(self._scroll_card_option("upcoming_center", "vs") or "vs").lower() + return mode if mode in ("vs", "date_time") else "vs" + + def _format_game_date(self, date_text: str) -> str: + """Render a date as "Sep 19" (default) or "9/19" (``date_format``).""" + raw = str(date_text or "").strip() + if not raw or self._scroll_card_option("date_format", "abbrev") == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + if len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): + month = int(parts[0]) + if 1 <= month <= 12: + return f"{self._MONTH_ABBR[month - 1]} {int(parts[1])}" + return raw + + def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: + """Draw the middle of an upcoming card. + + Never a score: an upcoming game has not started, so the extractor's + 0-0 is noise. Either "VS" (default) or the date and time stacked. + """ + if self._upcoming_center_mode() == "vs": + vs_text = "VS" + vs_width = draw.textlength(vs_text, font=self.fonts['score']) + vs_x = (self.display_width - vs_width) // 2 + vs_y = (self.display_height // 2) - 3 + self._draw_text_with_outline( + draw, vs_text, (vs_x, vs_y), self.fonts['score'] + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + date_text = self._format_game_date(date_text) + font = self.fonts.get('detail') or self.fonts['time'] + lines = [t for t in (date_text, time_text) if t] + if not lines: + return + line_h = 7 + top = (self.display_height // 2) - (len(lines) * line_h) // 2 + for i, line in enumerate(lines): + width = draw.textlength(line, font=font) + self._draw_text_with_outline( + draw, line, ((self.display_width - width) // 2, top + i * line_h), font + ) + + def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: + """(date, time) for an upcoming card, from the extractor's flat keys.""" + return ( + str(game.get("game_date", "") or ""), + str(game.get("game_time", "") or ""), + ) + def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: - """Draw status elements for an upcoming soccer game.""" - # Game time (Top center) - game_time = game.get("game_time", "") - if game_time: - time_width = draw.textlength(game_time, font=self.fonts['time']) + """Draw date/time around an upcoming card: time top, date bottom. + + Skipped when the date and time are stacked in the middle instead -- + drawing both would print them twice. + """ + if self._upcoming_center_mode() != "vs": + return + + date_text, time_text = self._upcoming_date_and_time(game) + date_text = self._format_game_date(date_text) + + if time_text: + time_width = draw.textlength(time_text, font=self.fonts['time']) time_x = (self.display_width - time_width) // 2 - time_y = 1 - self._draw_text_with_outline(draw, game_time, (time_x, time_y), self.fonts['time']) - - # Game date (Bottom center) - game_date = game.get("game_date", "") - if game_date: - date_width = draw.textlength(game_date, font=self.fonts['detail']) + self._draw_text_with_outline( + draw, time_text, (time_x, 1), self.fonts['time'] + ) + + if date_text: + date_font = self.fonts.get('detail') or self.fonts['time'] + date_width = draw.textlength(date_text, font=date_font) date_x = (self.display_width - date_width) // 2 - date_y = self.display_height - 7 - self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['detail']) - + # Measured, not a fixed -7: the detail font is 6px in most plugins + # but 10px in soccer and nrl, where "Sep 19" ran 5px past the card. + date_bottom = draw.textbbox((0, 0), date_text, font=date_font)[3] + date_y = max(0, self.display_height - date_bottom - 1) + self._draw_text_with_outline( + draw, date_text, (date_x, date_y), date_font + ) + def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None: """Draw odds with dynamic positioning.""" try: diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index a27e4627..358245f5 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "2.7.0", + "version": "2.8.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -26,6 +26,12 @@ "soccer_upcoming" ], "versions": [ + { + "version": "2.8.0", + "released": "2026-08-06", + "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.7.0", "released": "2026-08-05", diff --git a/plugins/soccer-scoreboard/scroll_display.py b/plugins/soccer-scoreboard/scroll_display.py index 467ee57d..2e27294d 100644 --- a/plugins/soccer-scoreboard/scroll_display.py +++ b/plugins/soccer-scoreboard/scroll_display.py @@ -260,7 +260,7 @@ def prepare_scroll_content( # Get scroll settings scroll_settings = self._get_scroll_settings() - gap_between_games = scroll_settings.get("gap_between_games", 24) + gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) game_card_width = scroll_settings.get("game_card_width", 128) @@ -319,7 +319,10 @@ def prepare_scroll_content( # Add horizontal padding to prevent logos from being cut off at edges # Logos are positioned at -10 and display_width+10, so we need padding - padding = 12 # Padding on each side to ensure logos aren't cut off + # Half the gap each side, so adjacent cards are separated by exactly + # gap_between_games. Baking it into the card matters for Vegas, which + # stitches its own items and never sees the scroll helper item_gap. + padding = max(4, gap_between_games // 2) padded_width = game_img.width + (padding * 2) padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) padded_img.paste(game_img, (padding, 0)) @@ -341,7 +344,7 @@ def prepare_scroll_content( # Create scrolling image using ScrollHelper self.scroll_helper.create_scrolling_image( content_items, - item_gap=gap_between_games, + item_gap=0, # spacing already baked into each card element_gap=0 # No element gap - each item is a complete game card ) diff --git a/plugins/soccer-scoreboard/scroll_display_legacy.py b/plugins/soccer-scoreboard/scroll_display_legacy.py index 10bff4b3..115672f1 100644 --- a/plugins/soccer-scoreboard/scroll_display_legacy.py +++ b/plugins/soccer-scoreboard/scroll_display_legacy.py @@ -342,7 +342,7 @@ def prepare_scroll_content( # Get scroll settings scroll_settings = self._get_scroll_settings() - gap_between_games = scroll_settings.get("gap_between_games", 24) + gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) game_card_width = scroll_settings.get("game_card_width", 128) @@ -401,7 +401,10 @@ def prepare_scroll_content( # Add horizontal padding to prevent logos from being cut off at edges # Logos are positioned at -10 and display_width+10, so we need padding - padding = 12 # Padding on each side to ensure logos aren't cut off + # Half the gap each side, so adjacent cards are separated by exactly + # gap_between_games. Baking it into the card matters for Vegas, which + # stitches its own items and never sees the scroll helper item_gap. + padding = max(4, gap_between_games // 2) padded_width = game_img.width + (padding * 2) padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) padded_img.paste(game_img, (padding, 0)) @@ -423,7 +426,7 @@ def prepare_scroll_content( # Create scrolling image using ScrollHelper self.scroll_helper.create_scrolling_image( content_items, - item_gap=gap_between_games, + item_gap=0, # spacing already baked into each card element_gap=0 # No element gap - each item is a complete game card ) From 7226aef7e46d9ecfc3d35b5d092b9b28bcb6118b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 17:44:39 -0400 Subject: [PATCH 5/9] fix(sports): cover football's adaptive path and match separator spacing Two gaps found while testing the eight plugins on the panel. football's adaptive renderer was missed. layout_mode "adaptive" returns from _render_game_card_adaptive before any of the classic-path code runs, so it still drew the score unconditionally (0-0 on upcoming games) and used the raw numeric date. devpi runs football with layout_mode: adaptive, so this was exactly the configuration under test. The adaptive path now gates the score to live/recent, draws VS in the score region for upcoming, honours upcoming_center, and formats the date through _format_game_date. Its goldens are regenerated for recent and upcoming; the live goldens came out pixel-identical and are left untouched. League separator icons keep the card rhythm. The leading icon was padded by a fixed 4px per side, which looked wrong once cards moved to 48px apart. It now uses the same gap_between_games // 2 as the cards. Confirmed on devpi: the league icon leads each run, then the games. Harness 168/168 PASS, football adaptive suite 27/27. --- plugins/afl-scoreboard/scroll_display.py | 11 +++-- .../afl-scoreboard/scroll_display_legacy.py | 11 +++-- plugins/baseball-scoreboard/scroll_display.py | 7 ++- .../scroll_display_legacy.py | 7 ++- .../basketball-scoreboard/scroll_display.py | 11 +++-- .../scroll_display_legacy.py | 11 +++-- plugins/football-scoreboard/game_renderer.py | 45 ++++++++++++------ plugins/football-scoreboard/manifest.json | 2 +- plugins/football-scoreboard/scroll_display.py | 11 +++-- .../scroll_display_legacy.py | 11 +++-- .../test/golden-adaptive/128x32/recent.png | Bin 3422 -> 3433 bytes .../test/golden-adaptive/128x32/upcoming.png | Bin 3450 -> 3454 bytes .../test/golden-adaptive/128x64/recent.png | Bin 4856 -> 4870 bytes .../test/golden-adaptive/128x64/upcoming.png | Bin 5178 -> 5429 bytes .../test/golden-adaptive/256x128/recent.png | Bin 13155 -> 13208 bytes .../test/golden-adaptive/256x128/upcoming.png | Bin 13550 -> 14175 bytes plugins/hockey-scoreboard/scroll_display.py | 11 +++-- .../scroll_display_legacy.py | 11 +++-- plugins/lacrosse-scoreboard/scroll_display.py | 11 +++-- .../scroll_display_legacy.py | 11 +++-- plugins/nrl-scoreboard/scroll_display.py | 11 +++-- .../nrl-scoreboard/scroll_display_legacy.py | 11 +++-- plugins/soccer-scoreboard/scroll_display.py | 11 +++-- .../scroll_display_legacy.py | 11 +++-- 24 files changed, 140 insertions(+), 75 deletions(-) diff --git a/plugins/afl-scoreboard/scroll_display.py b/plugins/afl-scoreboard/scroll_display.py index 0d3b2333..cc7b6e4c 100644 --- a/plugins/afl-scoreboard/scroll_display.py +++ b/plugins/afl-scoreboard/scroll_display.py @@ -211,6 +211,9 @@ def prepare_scroll_content( scroll_settings = self._get_scroll_settings() gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) + # Match the gap used between game cards so the leading league + # icon sits in the same rhythm as the cards that follow it. + sep_pad = max(4, gap_between_games // 2) game_card_width = scroll_settings.get("game_card_width", 128) # Reuse the cached renderer (rebuilding it -- and reloading its @@ -244,9 +247,9 @@ def prepare_scroll_content( # First league - add separator separator = self._separator_icons.get(game_league) if separator: - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + sep_img = Image.new('RGB', (separator.width + sep_pad * 2, self.display_height), (0, 0, 0)) y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) + sep_img.paste(separator, (sep_pad, y_offset), separator) content_items.append(sep_img) self.logger.debug(f"Added {LEAGUE_NAMES.get(game_league, game_league)} separator icon (first league)") elif game_league != current_league: @@ -254,10 +257,10 @@ def prepare_scroll_content( separator = self._separator_icons.get(game_league) if separator: # Create a separator image with proper background - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + sep_img = Image.new('RGB', (separator.width + sep_pad * 2, self.display_height), (0, 0, 0)) # Center the separator vertically y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) + sep_img.paste(separator, (sep_pad, y_offset), separator) content_items.append(sep_img) self.logger.debug(f"Added {LEAGUE_NAMES.get(game_league, game_league)} separator icon") diff --git a/plugins/afl-scoreboard/scroll_display_legacy.py b/plugins/afl-scoreboard/scroll_display_legacy.py index a3a4b1ad..01fe7629 100644 --- a/plugins/afl-scoreboard/scroll_display_legacy.py +++ b/plugins/afl-scoreboard/scroll_display_legacy.py @@ -301,6 +301,9 @@ def prepare_scroll_content( scroll_settings = self._get_scroll_settings() gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) + # Match the gap used between game cards so the leading league + # icon sits in the same rhythm as the cards that follow it. + sep_pad = max(4, gap_between_games // 2) game_card_width = scroll_settings.get("game_card_width", 128) # Reuse the cached renderer (rebuilding it -- and reloading its @@ -334,9 +337,9 @@ def prepare_scroll_content( # First league - add separator separator = self._separator_icons.get(game_league) if separator: - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + sep_img = Image.new('RGB', (separator.width + sep_pad * 2, self.display_height), (0, 0, 0)) y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) + sep_img.paste(separator, (sep_pad, y_offset), separator) content_items.append(sep_img) self.logger.debug(f"Added {LEAGUE_NAMES.get(game_league, game_league)} separator icon (first league)") elif game_league != current_league: @@ -344,10 +347,10 @@ def prepare_scroll_content( separator = self._separator_icons.get(game_league) if separator: # Create a separator image with proper background - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + sep_img = Image.new('RGB', (separator.width + sep_pad * 2, self.display_height), (0, 0, 0)) # Center the separator vertically y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) + sep_img.paste(separator, (sep_pad, y_offset), separator) content_items.append(sep_img) self.logger.debug(f"Added {LEAGUE_NAMES.get(game_league, game_league)} separator icon") diff --git a/plugins/baseball-scoreboard/scroll_display.py b/plugins/baseball-scoreboard/scroll_display.py index 5898d954..460d6f3d 100644 --- a/plugins/baseball-scoreboard/scroll_display.py +++ b/plugins/baseball-scoreboard/scroll_display.py @@ -204,6 +204,9 @@ def prepare_scroll_content( scroll_settings = self._get_scroll_settings() gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) + # Match the gap used between game cards so the leading league + # icon sits in the same rhythm as the cards that follow it. + sep_pad = max(4, gap_between_games // 2) game_card_width = scroll_settings.get("game_card_width", self.display_width) # Get or create cached game renderer; default card width is the full display width @@ -228,10 +231,10 @@ def prepare_scroll_content( separator = self._separator_icons.get(game_league) if separator: # Create a separator image with proper background - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + sep_img = Image.new('RGB', (separator.width + sep_pad * 2, self.display_height), (0, 0, 0)) # Center the separator vertically y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) + sep_img.paste(separator, (sep_pad, y_offset), separator) content_items.append(sep_img) context = "at start" if current_league is None else "" self.logger.debug(f"Added {game_league} separator icon {context}".strip()) diff --git a/plugins/baseball-scoreboard/scroll_display_legacy.py b/plugins/baseball-scoreboard/scroll_display_legacy.py index 67a49067..7dc7e53f 100644 --- a/plugins/baseball-scoreboard/scroll_display_legacy.py +++ b/plugins/baseball-scoreboard/scroll_display_legacy.py @@ -358,6 +358,9 @@ def prepare_scroll_content( scroll_settings = self._get_scroll_settings() gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) + # Match the gap used between game cards so the leading league + # icon sits in the same rhythm as the cards that follow it. + sep_pad = max(4, gap_between_games // 2) game_card_width = scroll_settings.get("game_card_width", self.display_width) # Get or create cached game renderer; default card width is the full display width @@ -382,10 +385,10 @@ def prepare_scroll_content( separator = self._separator_icons.get(game_league) if separator: # Create a separator image with proper background - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + sep_img = Image.new('RGB', (separator.width + sep_pad * 2, self.display_height), (0, 0, 0)) # Center the separator vertically y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) + sep_img.paste(separator, (sep_pad, y_offset), separator) content_items.append(sep_img) context = "at start" if current_league is None else "" self.logger.debug(f"Added {game_league} separator icon {context}".strip()) diff --git a/plugins/basketball-scoreboard/scroll_display.py b/plugins/basketball-scoreboard/scroll_display.py index 9a0dfcde..9b3cc2ad 100644 --- a/plugins/basketball-scoreboard/scroll_display.py +++ b/plugins/basketball-scoreboard/scroll_display.py @@ -215,6 +215,9 @@ def prepare_scroll_content( scroll_settings = self._get_scroll_settings() gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) + # Match the gap used between game cards so the leading league + # icon sits in the same rhythm as the cards that follow it. + sep_pad = max(4, gap_between_games // 2) game_card_width = scroll_settings.get("game_card_width", 128) # Verify GameRenderer is available @@ -257,10 +260,10 @@ def prepare_scroll_content( separator = self._separator_icons.get(separator_key) if separator: # Create a separator image with proper background - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + sep_img = Image.new('RGB', (separator.width + sep_pad * 2, self.display_height), (0, 0, 0)) # Center the separator vertically y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) + sep_img.paste(separator, (sep_pad, y_offset), separator) content_items.append(sep_img) self.logger.debug(f"Added {separator_key} separator icon at start") elif separator_key != current_league: @@ -268,10 +271,10 @@ def prepare_scroll_content( separator = self._separator_icons.get(separator_key) if separator: # Create a separator image with proper background - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + sep_img = Image.new('RGB', (separator.width + sep_pad * 2, self.display_height), (0, 0, 0)) # Center the separator vertically y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) + sep_img.paste(separator, (sep_pad, y_offset), separator) content_items.append(sep_img) self.logger.debug(f"Added {separator_key} separator icon") diff --git a/plugins/basketball-scoreboard/scroll_display_legacy.py b/plugins/basketball-scoreboard/scroll_display_legacy.py index 8213b396..39296be8 100644 --- a/plugins/basketball-scoreboard/scroll_display_legacy.py +++ b/plugins/basketball-scoreboard/scroll_display_legacy.py @@ -354,6 +354,9 @@ def prepare_scroll_content( scroll_settings = self._get_scroll_settings() gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) + # Match the gap used between game cards so the leading league + # icon sits in the same rhythm as the cards that follow it. + sep_pad = max(4, gap_between_games // 2) game_card_width = scroll_settings.get("game_card_width", 128) # Verify GameRenderer is available @@ -396,10 +399,10 @@ def prepare_scroll_content( separator = self._separator_icons.get(separator_key) if separator: # Create a separator image with proper background - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + sep_img = Image.new('RGB', (separator.width + sep_pad * 2, self.display_height), (0, 0, 0)) # Center the separator vertically y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) + sep_img.paste(separator, (sep_pad, y_offset), separator) content_items.append(sep_img) self.logger.debug(f"Added {separator_key} separator icon at start") elif separator_key != current_league: @@ -407,10 +410,10 @@ def prepare_scroll_content( separator = self._separator_icons.get(separator_key) if separator: # Create a separator image with proper background - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + sep_img = Image.new('RGB', (separator.width + sep_pad * 2, self.display_height), (0, 0, 0)) # Center the separator vertically y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) + sep_img.paste(separator, (sep_pad, y_offset), separator) content_items.append(sep_img) self.logger.debug(f"Added {separator_key} separator icon") diff --git a/plugins/football-scoreboard/game_renderer.py b/plugins/football-scoreboard/game_renderer.py index 3a7d309f..13484d05 100644 --- a/plugins/football-scoreboard/game_renderer.py +++ b/plugins/football-scoreboard/game_renderer.py @@ -794,13 +794,20 @@ def _render_game_card_adaptive(self, game: Dict[str, Any], x, y = slot.align_xy(ifit.width, ifit.height) main_img.paste(ifit.image, (x, y), ifit.image) - # Score — largest crisp font that fits the center region - score_text = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" + # Score — largest crisp font that fits the center region. Only drawn + # once a game has started: an upcoming game has no score, so the + # extractor's 0-0 was a placeholder, not a result. score_region = self._region_for(regs.score_area, 'score') - score_fit = self._fit_element('score', score_text, score_region, - ADAPTIVE_LADDER_HEADLINE) - self._draw_fit_outline(draw_overlay, score_fit, score_region, - fill=self._score_color_for(game, game_type)) + if game_type in ("live", "recent"): + score_text = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" + score_fit = self._fit_element('score', score_text, score_region, + ADAPTIVE_LADDER_HEADLINE) + self._draw_fit_outline(draw_overlay, score_fit, score_region, + fill=self._score_color_for(game, game_type)) + elif game_type == "upcoming" and self._upcoming_center_mode() == "vs": + vs_fit = self._fit_element('score', "VS", score_region, + ADAPTIVE_LADDER_HEADLINE) + self._draw_fit_outline(draw_overlay, vs_fit, score_region) if game_type == "live": self._draw_live_status_adaptive(draw_overlay, game, regs) @@ -811,16 +818,26 @@ def _render_game_card_adaptive(self, game: Dict[str, Any], ADAPTIVE_LADDER_TEXT) self._draw_fit_outline(draw_overlay, fit, self._region_for(regs.status_band, 'status_text')) - self._draw_bottom_center_adaptive(draw_overlay, game.get("game_date", ""), - regs, 'date') + self._draw_bottom_center_adaptive( + draw_overlay, self._format_game_date(game.get("game_date", "")), + regs, 'date') elif game_type == "upcoming": + game_date = self._format_game_date(game.get("game_date", "")) game_time = game.get("game_time", "") - if game_time: - region = self._region_for(regs.status_band, 'time') - fit = self._fit_element('time', game_time, region, ADAPTIVE_LADDER_TEXT) - self._draw_fit_outline(draw_overlay, fit, region) - self._draw_bottom_center_adaptive(draw_overlay, game.get("game_date", ""), - regs, 'date') + if self._upcoming_center_mode() != "vs": + # Date and time stacked in the middle instead of top/bottom. + stacked = " ".join(t for t in (game_date, game_time) if t) + if stacked: + fit = self._fit_element('score', stacked, score_region, + ADAPTIVE_LADDER_TEXT) + self._draw_fit_outline(draw_overlay, fit, score_region) + else: + if game_time: + region = self._region_for(regs.status_band, 'time') + fit = self._fit_element('time', game_time, region, ADAPTIVE_LADDER_TEXT) + self._draw_fit_outline(draw_overlay, fit, region) + self._draw_bottom_center_adaptive(draw_overlay, game_date, + regs, 'date') game_league = game.get("league", "nfl") if self._get_display_option(game_league, "show_odds") and game.get('odds'): diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 20a696a0..9259208e 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -27,7 +27,7 @@ { "version": "2.13.0", "released": "2026-08-06", - "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged.", + "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged. The adaptive layout path (layout_mode: adaptive) gets the same treatment: it drew the score unconditionally and used the raw numeric date, so it needed fixing separately from the classic path. Adaptive golden images regenerated for recent and upcoming; the live goldens are pixel-identical and unchanged.", "ledmatrix_min_version": "2.0.0" }, { diff --git a/plugins/football-scoreboard/scroll_display.py b/plugins/football-scoreboard/scroll_display.py index 737a8660..53177623 100644 --- a/plugins/football-scoreboard/scroll_display.py +++ b/plugins/football-scoreboard/scroll_display.py @@ -174,6 +174,9 @@ def prepare_scroll_content( scroll_settings = self._get_scroll_settings() gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) + # Match the gap used between game cards so the leading league + # icon sits in the same rhythm as the cards that follow it. + sep_pad = max(4, gap_between_games // 2) game_card_width = scroll_settings.get("game_card_width", 128) # Create game renderer using game_card_width so cards are a fixed size @@ -204,10 +207,10 @@ def prepare_scroll_content( separator = self._separator_icons.get(game_league) if separator: # Create a separator image with proper background - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + sep_img = Image.new('RGB', (separator.width + sep_pad * 2, self.display_height), (0, 0, 0)) # Center the separator vertically y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) + sep_img.paste(separator, (sep_pad, y_offset), separator) content_items.append(sep_img) self.logger.debug(f"Added {game_league} separator icon at start") elif game_league != current_league: @@ -215,10 +218,10 @@ def prepare_scroll_content( separator = self._separator_icons.get(game_league) if separator: # Create a separator image with proper background - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + sep_img = Image.new('RGB', (separator.width + sep_pad * 2, self.display_height), (0, 0, 0)) # Center the separator vertically y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) + sep_img.paste(separator, (sep_pad, y_offset), separator) content_items.append(sep_img) self.logger.debug(f"Added {game_league} separator icon") diff --git a/plugins/football-scoreboard/scroll_display_legacy.py b/plugins/football-scoreboard/scroll_display_legacy.py index e8e0bf77..2af68e91 100644 --- a/plugins/football-scoreboard/scroll_display_legacy.py +++ b/plugins/football-scoreboard/scroll_display_legacy.py @@ -315,6 +315,9 @@ def prepare_scroll_content( scroll_settings = self._get_scroll_settings() gap_between_games = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) + # Match the gap used between game cards so the leading league + # icon sits in the same rhythm as the cards that follow it. + sep_pad = max(4, gap_between_games // 2) game_card_width = scroll_settings.get("game_card_width", 128) # Create game renderer using game_card_width so cards are a fixed size @@ -345,10 +348,10 @@ def prepare_scroll_content( separator = self._separator_icons.get(game_league) if separator: # Create a separator image with proper background - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + sep_img = Image.new('RGB', (separator.width + sep_pad * 2, self.display_height), (0, 0, 0)) # Center the separator vertically y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) + sep_img.paste(separator, (sep_pad, y_offset), separator) content_items.append(sep_img) self.logger.debug(f"Added {game_league} separator icon at start") elif game_league != current_league: @@ -356,10 +359,10 @@ def prepare_scroll_content( separator = self._separator_icons.get(game_league) if separator: # Create a separator image with proper background - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + sep_img = Image.new('RGB', (separator.width + sep_pad * 2, self.display_height), (0, 0, 0)) # Center the separator vertically y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) + sep_img.paste(separator, (sep_pad, y_offset), separator) content_items.append(sep_img) self.logger.debug(f"Added {game_league} separator icon") diff --git a/plugins/football-scoreboard/test/golden-adaptive/128x32/recent.png b/plugins/football-scoreboard/test/golden-adaptive/128x32/recent.png index 98caf05851127d95e66b92b7a1f87ffe11eb23af..cfee31f0c35fcdc8e3f5c095f93a146658de6ed2 100644 GIT binary patch delta 3430 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 IM6N<$g2rXJCjbBd delta 3419 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 delta 3447 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 Z{{gLZB2Z+Ul zO?vFuv17-M9XodH*s){BjvYIXiteW4Bmq1I%N#x+0HW0p?FUK^(m^T$isFFe5eWbU z=1E`?$C-^xeIuJ&1=%G~@Cz_ZTUv?)j)z{pHbW2u&-3x|@n3%VCC~Hq_4WDr`7JFi zp`oE7k*KJsX!q{jSy@?Pu~@Iy3rQ%dZ7qlAd7kGVAk)qrKoY>UYYW%F1$ndJJYBcnlbTL?A+rQlhUM1-SFUp0wIc?@qO7>x{>8;q)b->5gi9 z8^Y3%OeV|A%Uis7@#)j2{rvn?DwU(7V{vhDYisMUVZ#!~ zT8jpfBrPjZJzodx=m3O(Ky7AvejL0qiquhxQ)O-^3MIFzSuoH*0m!8aHy2M=7lpI4 z)Iov(nP^(4*Vi>Q*J_)gNe^ZgM3lIXWcWaCd{A?+vo`CxY5Os#uLl4i2za1ad9(=O zuU4z~?%f+59sS8CpLltB6%-UyS653U5&$@H;zUJ7g_Dz$(P&gC6tic~o;Pn^YHDgt zO^w-XK6dO_MMZ^08%dHp&-cU)_c*{}nsFmw*<|8|*>iiAM}8p>1_Shw#b{Jd4IUB~ zJ}B5fu)n95i_DoMNvqVjMX#^bHk4G9=loKTc_sUNenCrB9e_mPT5CkFIAcW*{`Ps8)pLpVl^XJcpg@tvH+`D)0_3PI)8jZnV*tTt3 zWo2c2e7u{RTc19C5)u*)9z1y9z=8VudYw*ZG#UYbqNv}{G?4@VfLsPCE8w3=ZV}Ih zfxiX<{DUWt|J&A0`9+2JURaK!8OBW0W}2oMhUK^p0+qLGzB;h)sX5a@69~RR(zvL1 zmkl_%fr``s08nK2#rcD2w^|I)V*%AtX9c?uLF9c(UPT!Hk)Z0cMZd`W}0RghU4%0BklaT(Q{u0wJ!vQhd&dRu~{}F9-!Cw zK&ey)1O!kNHFfIL88c=qTD0i%&p(fdh~R8O3S-PL4BL+N^GoM1L=PDvC?rX8)v8rR zMMW!Dt_%+k7arPrS^zvJ9hi_?R9l^PEJmRg@uicjq&mESFc{3IB{YJ$-=4%FS$bD>6X`HYMVOA|D6=FFm^A|D^0c7xwr z6-WXc9pU0eB4Sb~1gO-{JzZK|g)ycXMric}>lfTCdHS{45E1~v{t%@R#SW6hMOt5? z*g-Hj6f^;yemnpeO(u+Sc}-2=vrqRP2MEN*#^&bcR#jD*&E~Gkzrn%#z<~ppOJ6vT}RL`@Q5O0R{x+q8_u_(>iY1;PH}xJYqaBoz}u zMu*#8R7`|8HnP*twtjfKq2BiMx-s0toBqCUmo8n}X;G~o?oN0aW6WZl@#Cqlo=hka ztFgzha~^}fcyrOITg3$}+B-bcggbWB0&#J105EOZv|oSyb@1T9f)9jNn)301FtyWP zzjZ?r?Z%ZK2Pu^d%Zexp0C=7!2qM4u#`Ax9)5tIqg@R?-?!TGCU5+jfoXWsFrdh^8 zEO{`SK05OA^Ld_US+-+rTH);Ebh*4V@4wb-4xX9MJAlHyxuxasAdkr4K+}J&FD?JI z%xIz&&PQ$>&u`EkbRHvs}5JF%1N4o(zwXBI2Q>;uX#Rwx* zMH}|ERxAZ?5f~Xj6aWHBCldeM*D0W;xdfYiOV6qYC~3^M~@y&PfuUI zd^yILiV384opAzEH)qmR3}DIA68@RlS(LN$J+Y4v%P_#u09FYAx7~*X008__YV+xq zTE<3#s+QEPnQQ3R*!3ygWSErktIn2A;fbWkFi`X-@0|{!i5VX zA|iI|*m3OGu^BUFP@LGIN{dK^EH*3x0a)Ui$Cv;pxLJZtMp7bS+h?u_A)aOXd-YZJ z^#&x2O$h=a)cy~F2!v3EjRT&Kf0jT5K_CDiNwWIRo%2_+8}(wAk^s=qv)J!Rm(bN2 z#cm|!a=8%x-Kf1uaha22#P7V~_1TU$S?2}pt-m|?IZ)t7I84`>3Pb#7z4|Hul(#k~ zty{PAzqjml4RbItEXxTQQ6wq!sT((L96fsUv}x1UtXXsT@L~AJdIf}syF3|FU3&*( z%(c(=gaPP3(vG8GKS^AqrKgp|MM98r`DdSY+S_#1m4QvqcKUJ_X#626%GylU@?AOL zzVtj501Ox~AT>2r=m5Ge{|5mPr*2%^{BGiP@Wg#aZgBShlluUpB8G8~#jbH@kaEXo zn@lE?&<(%!{=1_Amib52G}H@Ye?bq&ab~j_V|?Yx6@*YzQxicU3?!In#$+`DgZ8SR zQu<^N)G_& z^?FA~#}27F9c#=gZT@b_`-X!*gvk9`o9o2W#(Vbb$F{a2hGRH(q{PjfxbY5c!hl(p z-SXkuzg^3J`)X!iH+N{SY8=NA1R)lSFJ8R3eEIU7J9lo|wvCYRX1RB?;CY_oIF@4>mKD~8OlGsuWYQarwjV$eBuS706iHtB z#0N3t@RgapRH*ZoDrPJwpBK)=|HhH=FG@E!4NpmdU&+DcHJtu2?T6{a+p?0KG zZRs;Z`-mM_hM_3R1faiCP_+BIJ9TvkA)e}qX`3(nd3N~=RFm!f@BS2`g%oTLh{#_9GwEDC3tWg zNslWK0A^X&_wHc15TlLJbjWTN8BEE+_BZ4JxHW+~C9O9lMu&V0zLwSm1e{ zraO+rx=D-sjnX@W?+bq7pF4KumzLZ%wARt4lDa$RvM;A3Z`=?!w#p&&8Yp*sk}O1} zUa#N2eLDc8rlu}iwoERU3wjVjh$P|Y8tRSHn#ywhQ%?>%{nc(^=g_ib!SOsv5J}(c zTJ`y7ty(Qe9VxMdB*_l*03KuWrQDua5Fw-(9?^6MKA7H6ezp4VM<2=9zvsRb&Qx39 z+*iH;1%&qL2Q*XJqP^-OX>^mZ6yao``!*R=de)O8&w?&{@ykn-J_dj-TeiIa{`+RL zIVB|pA@s^CuK)l=QNk(%0KlkN*!QU`Xof(Da`s?W;d>mP$E}LkJ1d9M6#i2>>RVzE)Cnz4T^zO-*Bqj$v6xvBcZM zBg8*o;=Nr+H;jBSWL0eOew&}4pP89??b@}9iV6=8kMiTNEad8o{BT2IJ1{NU@o`10vwyg1zL=T36Dl#(Y zKUOZyzE*HIW1eSNmSGv1xqC5|80$d()1QAK3FgfGB!!aO%I09DDd+a4bde0`L%D39r?Ak zw)@!c0L!vD*RQVnB3b?1c<}Xufg$6jhaCIV`Oh!4<@k7GU%=KV3l}cjv}x1u;lnwO zGnq`xy>$4-oSd8q6DGL3yUXQrS6A0Xix!GW7cpd`% zVa-frkh*=H>y>#!3{5Q{;@$dt#)fK!hDQ!khYTE`^mOx(%bX-4vCu;>ENd_s8+FZ9 zcWxJ!-MoD5>V>?#g2Ga0GJ@P;WH`(kR4>xif3P3lyw&FE-q7@em4So+PfJT%w{Bfr zT-@5VYi)&Tnodnk&CAPcXlS^4^=d&u!K6u(#*7)`?d|RE?w*#GcJScAva&L*Rtr5o z64B8)ma|jChQRCN5b5Ha`K!n25~t#7fMzprG>A!%ImlgP3YknMbrew)&+{glZf-Sb zb*-#U4+a`A0%vi6k{qu#j_9W~)irNDfphZj(ch~O2B40Pj`{idYPEXk(4k9~EV+IA zc5!iWMMZ_9qoa$9OMQL)g$ozNVsXEI{TPOcjg6fh%F2xG^Z%Y7 z3W82g#t{erwH**rD&g5Uhz+E$qqNc>yU`#mX%JO45e==_Y{I}|;4uIIg$N09b3#4} ztm?ytco+vbwIE~b#X>Ii64cZH2rYiQcyFU^bBdyJa&mU<+O=!fE*~Esl}aU-%S|Rz zX=!O|Yinp|=**cj-QC@rnws=_eNIl!{{8z)OYbiFT299G(DZFtclClK0K?rQY<>Db zWH1cz2el`1cA+S-h({z(kQgHjKmtc%j-#2@7Pj~fpH&LEzd&7G=c;-q!pXSE$jId6 zWR*%)S67#vovqbs<#KsoV4!e7@z9||-+%wTwS6sH2*1_tcI?=(W5|!}1^(fX-Y<2!I`SzUN_&4KhEn;h_ z9@YcfgAUzPg~vj#b3iwiwRXcpwj1~n9E5o=dcn5rA5QPFKJ>yG@Tjp^|FN51pf`?! sy6OY0-}c0Oo$Bed-R~vNu=5!BAM+iTtcnXiBLDyZ07*qoM6N<$g0QP*S^xk5 literal 4856 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 eo!yTAT>KwnfTOPJ1%1o_0000Z+?NKVuGc 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 literal 5178 zcmV-A6vgX_P)3Zqw^ss@@+L+7^(YF_G_0|GxWuUT#;Ny7fD! zPF0;cUILyHFvi^<5(M!`RjlQ$KXrf7rLge4OR?VD{YgPSt%d~?g7By4 zd90wHZu1Yb{f9lbHGIMI^hJJ`y8Q}ISpj`VFDmJx4BIljkls)9(N7i73(?)n*#6W{ zXtl!*JM6H-4m<3y!wx&_u)_{JdPZ*>cZvjr!Mgbm41_o}#QB4-J0*1#0ZsEjA%X$` z5+e#Yg6AzJwyBl7TnG6TP<#zowxcdh0gs@61b`n3k|YsA=;UbZ!tgLLujvaY4;?ym_UzdJz;PVMac9q-UAJys zLqmgA0!2~QkW}BJ<=tamKoT7^{W=+58A<7A#fd7nW2KTiw>U5|Km*7f6>ct`t}Y5^ zXGf`o0K&{LI)kCH?OsF6J!mt4g#!^S_K*w<;>U*E3w3VED=}|51Wio<03->IWh*}| z1oKy`)w_1>ii?X|yLPRYmsfFd@vU38BoYY#96NTbrl!Wp$;o6gDHMvdw6vKsXC6L$ zxW2yLVzC@Lbf~7L#wv}XD1=a7)bM};tkfhAhea=u*Dan~^4yC`5g3i&A&b|jo*5FG z7#S5B5Io4!%SGl)QIt(>+-@*5v@}=LR2N(;&b^d>wy3zht`R_@@R9y5radL_&dE#0 z4POAq0Yw4NKT-aE1W2;uREi)7xmOaqSeE6{eSKt~Ju_;???LSc!I6=_ia5GKmO2jlj}Cl& zeFFmnX_}rmabjv}>iqfhKm71Rtyar-GbxNQ%d%W2Y@c5^doC_ERuD*0)bi!a%gV}@ zELjp687VyMu4w^;b#6@5!pE)&f_G)m;M9jP}@ zo@?2(4`>Rwr|OHwj2W|K%a+E*Mkgny($dn_*4A<3#$CB`B`qzjzP|o;QR z%1f?LI6GVOFdAB#TMT-UNQ_9r+0n^aB)VB~qo%g@*wLeznVEimelupw7(RSB!!WPC z_F7F%P0t=lP)ukL~mH+d6^vv45QY+2Mmid220>=Xr!M#^0A+SHGAHVL=c%&J2pb>>Yoc@?V$oJFEPoM~_-v(3B}t%F4=oe0(|ye{d*JBse(0 z`SqlBd^iNE)Gt0)d8-a%%&@HB^#mS^Z&W<@Y8r$ELTCWQXhaE7lEi4+OOy}=L&8B5 z2x`B$$rH~OUKWH1qm|`2li7?huCA{Se&IO?4U)#k^o5|GK>_jc@d*hDmoHzgtE;nE zEMI>4<(@rzFvgkd*Q*_!D_ykMH3_>Wt&w`~-MbfaJjS>MbF1HcYbSVN*WnBV9Z-Z# z9Tc*2%{t8UjL~Eicr6td+5Mv9wc>zy8Y{6U#~z3XbD? z?=6pcKp>VmvyA1Hzh&qQdM~At=MexPj4;9s$4bSLx?|t|cygZz-hKC707y<2-}0$+*>>bOUN)RU0Fw zIhms)CNM$NF~(i`nso3Mfr$lz1|WdeiNxOxa0;xySAi{lm8aE%eT5g$>2w(x896yQ z2M-?1$;rvc$iNuW@xk==Q%*qXrjMV9tz6lde{Oyr?d<%3?GpsYvcNI|tJY4zgJT_l z0Fvwo0NMUTJpFXME?BT&0mk^jeWk7Y!bn8TYC_Q7n(OX(V3g)u&e5+4KRHDWmN-qO z0~Sk-vD4BBiZnI1w0(Oqs`OT}UJi0)vjay_BtZ~}=MlzoxqRcsjq~Qs(`vO_w{AUj z=um2ED$R?fbu9#?kR?QD2>`2IBaBIa;u{s%Y@#F*u5;%~5Cr15L0$uV2Y3S^tW8Oh zAc)R?NP-l`2SJjg&5iIANfH3CrzG?7nCjE!{?SK_TrRgYf2$m^MABha7;eyf?o09F z*I)7a->q{v=h@uNe@XqNH29O!$vSgsSisb2(*U4ae{bEYRomYEXqRh*)XZ`mFNmTk zN|;mEuU|iE)Tl|5CaqYpV*mdAv>DNe!7d6%SGinh*7hYxg6z^8iX=I`e#*3&tLH6z z)PCOY+k20xfS(B6vm6P6U|F`ZvQkV-fFLLy?{Y^Y-dkF-?TeH{r^7tL?wV`TL=8Ln z0hmGw5*O3-H~-b9oATOhKPQ*+dpV44C7%kKSJSkE=z0zLGn@O=*?S^E^qCVzK!A`STeW8QZpP+q7vD zEkPE!jMO#hN^e#L4Dvw;krXBD7cj<=Au3IX>e}^dA_qrXH3%Ctgb)va0b~5Y@(y84 zkfg+O%oS&CMAZ z8J?b=vuDpHK3hzDQW<*gqIr5+>el7&a6C_U&PBqCXwR{n-~Q=UsZz-ygfS)nFhVYJ z7cZq!*r^FiUu&!iVNBDMuC2|_XK?mM|8!R;@DS)jl4^S;DI9z z(#S7DN9@_Ut*El%j#1ypm@69Zp2@$sIdlD*#4&Z!@T=gvb#10#l?H=h%a$zwaQN`y zMT-{6h3QtX0E&Xc@6v;lwV(=mX67GwgoO9#qJ#H$ekx0hh7ezfQq%ETNm7i%&>^;$ zBq@f7jexK~2oIjPVD7EPhKI3VSYDK~?tF7kKn+7%ui-Xq<@u(pC@ zS&Z?if{Wu^Jd24y>^TA}5^JcC!)_6u1t)zPI&6<`T&`Smmr`K}I|YN*E_g!_^5KUc zy12MVrP3WccI@1_Q)ri_>H8ycWCDEgzAI>AALl1mJvTl&`+v>q@<** ztgN9!hq5f2o11&%#*LDalB}$(nwlD0r`m;wb4B03Ja%ll#QPu&suzV~Q8*?w7(y|q zn;`g@C}3Uu=p$c$jWI4RDw;fbazaAF&Ye3iU%tF@gw(;^!_-o85{^>w)5VC_~XZp`T6+7 z#m277Tvt#~uwlan7Z(?yIf|lsURDr9$M);vbogk6mn3co1gWU#kUuO*&%auHzhQ(} zj$=8NVeen8^@d|Pj^lWqeekr%U^4AEn5CWcEAaJ$sHnG>4&J?tkjcP$6!CO1X3UsX zt5!{&I#r=i3>YwA=+L2gd3j-BVN6d4e?q{H6?$zgz@xU7mIGN?aWO+AVlhDwt5>hS zaN)wtnKOffgN0TeO1%|U9n78tM?Y{(o*W9{G2kB{iq)jdef6_L2W~es^ltlIz;Rqb z$(7|FXR2Qu3x56(6qY+D7~0xFgxm&sCWLE}BBP_!VL^j^J>A^pGAD^hEI34#@5Odyv&8wpdRnnb#$zwgF3Zgyz8pCoBn2@(uEJ3D*Ts#S@Ji7QvG>|U5* zn8Sw;7Zw&aH#c9oa;3Pqc>MVBqeqYS_V!jPmD$Y@!rN`xS$C7ZElwE-~1dL9EysH)M|B7QqsbO3-8>yQ(j(PQ&Z#M z;Naro($v&+?%X-CSUhmxK$c|_5)x+2n312Kf8fA@s;a80swyEB<&lztdd`d^2>_xa zLCDt^UPy$5U>Z9()*5Bkn;k2fMRjdtvmRT_7&r_F0|3wjDIwgP2pg%Dy<98PCZ|FFjqUnNyg6-S4Z{NP%$HzydQpx3Vv)No(S*h3S!^6X; zOqrroD%;xH3@K;NFX}#B^wW>_^$~sfa;0ry>%H9tJpuebV86dBpu5}Y^1bLj%CVis`xX2BXchFR oUq*izk3GtHj4!p@@!x{~1Lm26n~E|$i2wiq07*qoM6N<$f)tzl`2YX_ diff --git a/plugins/football-scoreboard/test/golden-adaptive/256x128/recent.png b/plugins/football-scoreboard/test/golden-adaptive/256x128/recent.png index 7f1f9578f7b521de5c380dc7e00039315309ef03..64543d3d42434eec9426fe7c10a53cbc9c039273 100644 GIT binary patch literal 13208 zcmbumg;P}j8#cW3QnD!BsWb>E0#ZvaDY4Qi-7UR@qJVTs*OJm55(3iQ4bt6R&-wn| znfDKPo-@qO4m<4dId@(6bzSGXslTrhLP=L1}1Q-=~wV9^V2Z8tu z<)tJvKBpd}dH4`@k+v-l92)fM_aS2-V{{mDLBYYQxh!2!b#$U=bJ;GOM-I7)d+~iT zX$XW^qKJPdO?*2z8PzH(U2kr3G41A|qL=WwTm+;uWB)%>oA1mVQ(R_~ z26OQM?_NDKZqAblD!^6U$hxqf)Ee^j%Gu-WOa*iK0I#QKLFrSMYKNGte;v_Ni*VzM z9uo1*^sm3?$*%>BB8Tl%DA(Y_F&DQ7jJlwo-W1wjCPv=l`&JQAOmEcP?v)r6ds;1} z{O@mM?9mnw{t+$J{;tqG&;RdNwV)@}eFwgtg=du*Q4RUgoNabad`dodB0OW-YC@NL zcfyGo{_MbK(f<$EP4}1yE;=1L848r{c|j9@05kygw`Bo0`kjQT&9M!JfsmsMsw|c& zyLbSKDF_4$AZ=qpw4-~W5hY3*5Pl+VL$XKtVep=QsCaC=ct(Z?Mxhs9$8bJm0@K3^ z6BDPgXnl@HBnbqCgv>2044XfntoO#|E2s7K^ze}338ir{Ffj1*$3fH#4ARrmeE)TR z&(3C;->J>z`3t81K=dTIM^Ph=(MHCDD$#uTqmD8Z7kNTBB8U!sXq(55Bh%EK31qvp z#_*_#;rwON7p{oJNclVv1_o&J&A}9p({*`}DqB3fxTbW3505dF;U3M>4?`TxgQ-@0L0zhK7cmoc#Ioe=94hdU`&m>%HOO;lQUB7F$DU!UWVp{O{jWfns7~ ziE+LpCMJ$rPV8MYG&I;um+Jras_Fd{gNI5#EmGdP%**dE_|oY3O+4-5GZ7~-Y9HF* zn}FW71K9L0vHV4kUiuO#2@X6}bUcg%223qHJk0Q1 zH_K{haNWhD`&U^ZxW|QnPT}WkhF^0S#aCI)&v9zfll1WIY2}3;?(cbdd7Yh|p-|{9 zw$0j>=ArJ|yups0j|w{~;Y#O!eX_t9pvt+Nu1H8`pt z&nIF0*`#Uj$h>3%x;je*56vqM>wArOjVjKN7041A2o3~gtD3&E z|C|y7HpSG!)JibbN{}D}vR1r66Mcmc3`Tt3`Sl?AL!}FTPEcI>uj@yMQM=HLZ_!I~ z%>K!h+NkC@8WFGkU!Rj& zPg~oniJyM`s1UPG6~&feBU_sLJv4*zSwP9^=OXtdzs1I?hz(7gHljF-+C32lFkhY6E{mb^!0$AtMVaO4PSD(_|ySlbCDp&D`qY2KlL zcrY`a=QDrG5z}#TaR~}~&Q_YKh+GgMU9S!oe|ulmQZt5{n3*9*EkBXTl)(A$)HO8X zQd3itl0L7r1*9MsO7)v|#`7a-L?}Q-z(0c1=Clyc{?YI0;p(XMsimk02c(ry(!|Sz zJXhAhZejz;fWTms8dL*voLJeQNMRlCPR}8(x{jX|nPhQNy&uAPvWzOU+HFyx+OplX zEq2+^4xtdYZLM1} z+m&K{I2#tD_1Xs775Ue8GE<*5F{EFY;5dj&&@3(YuI`&mZSH6ise^a2z|q-6|p8$W$%2#!6_911cgL1MN$? zU9lLo-o$Uo$*&?j%!K*_NH7ZOEYSk58nqk@29uN9o~c;7IorxtN_lJg7f%ABmGDKV zakh4)lqu8pKr*oiw#cupruJVHJ3FOPI!rqzC*2n6om{qtq`_&z9`+l3@v;$AtgNiB ze<}ind2@3U!+vpfL6`VdDYEKDB^fl7T?#|R)rKe=@oHtb9KZ7H$;qJy3R z9g3!^SkEh}Lr&W6h8^;a1sq9n8tgx$gLEQo<}2ECC-bCV(M6%}bfSV0Kvms!@Vlv9G#3Z#O0Fl?igrav1w7#id~Z0(r$bUVd&H2T^)+8y=xj7(r$|mwACE;MIWFAPZI@{UPl^}v5jUwRIV_;wq zLhU!7CXl60QBmi~pBR8nd$~XB0?dZcP#g(JXlUqAhM1w278n!|5b*l->&M4Oj(#?x z(5VvLtAlxgckhz6ZdAk`TyM{JVost56#DCun9X`kt`CVbyN&j)BK$-;G9~%vnW2QF zS>Z?Mg!B-SBp&m1THUA0(`Kbv+(4s9FNpL9p=*k4mY-l@yWmgcwzS;l2I=R$cp>#H zVEmPT+4c26xZg$mKwOUMV_|IE7jn$vp_vkb$`xN+`IR~G3Bn6Cgwm9~{BKUps%94& z-E2T|ii){$r!@q>J-=mJR$;oaa&e^#IHc$2TXfv>;PF{c6f7+0!FC=W?n0hX7mZr( zKF*miGsw3#G49`>ZhyEQwlRPZVvI)j)jLZ2@JC>WKw6JwX|-F-syLT@)SVs9KJ7;z zQwltU?Gkh~|wC1cuIen)&5Q(WdF; z{XczLpD0wf7)*9s_PsI=pRaQe^MAbG-`@vHoKZFckDT|@;X*^)81{>optgb!IV{MS zsehD6Y|j&mkC+eNj zRr(CXPOAkjOs?U#=VCPH?|gW4O3~iP>A1|48^7(%i8H8oY&WS;g#onyWXvmHpu;H_ zPu^}M8ltuZ5s1n;45QARiL)X#H8o$UqysHsZ49aKN+~%PUTi0LG*LKyzB3LC12*cP zDJgej3f%Hyi`LZjhnB3>?aOGFept%Nw##Lx${W`46alJu;1e(3GMX#jtH%)Ppt<%R z6xVL_acWRotXy+5n)jXztQ+e;!_QL#?<@OY#j|*Upg2%G<_uY`>N_ib(L?>mrEH8vZyoVfP-UST5i0OKUKoYj)yt^4|YQkk4~JjuduDB8v3^^UstlaANzp>RwC!|0-*-(G37AAReo+T%cVF zETP3-_YZ>+sPB08ztKxhK6`e4YmPWSx$g>-&sVABP@p`wVhAo^aSuDBqM*>Zx*N|| z0bsx*0GDyTKzHRZKw@I8gp4xHcZW?vX@`l1ex92@Eo?a*ym8wvpE1%AEu^iCTxGhL zHTjan!Pheb}%?Tu+3Xdug!-Pb9}#%8A528UMSJ8v)?kveIH3J?A{y0yzKwiqlC#{t~qdWc};Cc^rFHg!3_rXTt%K1yfU+HK%n_^UETz4(j2 z%=M~R=;gt@I*fqoT^kQxx0895Hc{8{@$tWZ|01Y`|8{p9w)l7+%+-`NpW=X6h(f0g zybp@YTCADcE7IvfL!dADq*{aN-B7VvSBLN6+UK3`bRZlsN->BHHD6Va za0o5_R>)2{m?f0r$7X(p-|lRcg^7uYj*gDB^p|sW1B0bl715pbb$*l1ZvZS4blD^# zB~=i66JA#=tAr87;Pc#2X7Ty`1ZYa>(DzE4_5M{#<4ILx+sbOuEZc$&gu zzk@)~Z)Ri$5U=h|d&A>1#Ma+u)I;0-psD!<60>eV)OE7f?SFSrXI;}+R0Olu zMiuw-du;K&`vtf0@$r$9lUpMeUoYuyzek8N*V_ESIy8vs!|L@?FVbL>Mua!(5y<$? zXrzN+tS=RN*$U%TIA9Op&@hx#;-WVd`YcE{%Jvw8oVW-|0eheq0a=MeB45U`GBU0; z9kmEMuC!gBtWE#YKAX~Y5IGy*d!?K@17J4-v3pewjfv|v^1!)@rS_BU;iK&o1s> zaovqh+~6$KvQM)mVq80fi{D9uznV6>5lTgyB>`2Y;`5XSl(eyhMZ|{G7;m)izY0D# zvA2h)lMga|m#bB*S1M$t#L}ysg0mPgmPr<*q>0tl`~(C9y-JCxy_C-c-@qRa7u_|8 z6(dFtW-BK<29<ng0i9|vzy3gE?72miCJIk;*J)bFHQ*m1m9b(TPM0Yx@^zS zINzu84wAwq{tm9G>dmZ;X4OWs>Wkoy67ajfW>!d$dnG6+h)?-;KZMq=T#JL?{c&4` zY4^Jb8!|m~(69CTX0g!Q&#N@wMZaO-AYPkJCi>nF-CfGHvV?MU+*g?H^-LT51-foA zF?v?-pGHakI5doIl(rr}v`A60owMoX7RsNuVhH1;=H}*wg@vy<&6nZVQy3_`x&wi9 zM&fuK>+ySAey;_NnvmRat4sFnhypsD*L3WEmbFjo++1n-9;Wj#@i7p8cKe$DWsghcroroOjNGlb>-nyLbgjqKJf`+4urR8(0En}in^7ypL_fHnbOZ%)IO zW?*4wW@Z*hwQa*;_*|bUpR@f@cY*heIxYegV@gMJcCSVBD8e5Ok**yTb%}<2HI5n1 z3-`QpgBMD1czfhG#>eg1%+JJ4eN~1)!OSnP2x8gW1~cPyN~X@w&oeVKNq83HDmiRk z{u2lzJ$$?lTTSe1aXi5yP$uFagW!GxsfybSU@$JHlao%J1Zne?(Fk$nS`>!%*O(VW znNXoJ#1>92n{&4{yA&@L>ps>6qpQ?lf(GLDg;JStveC>NJt967PO2$*P5}_yYtz3#@almhk^;RPSe!;OwZlOxzh4>ub(y*Q9i^5NXv~U4U<=lo z*hw`RMME|>am05=UMCNxWss%&u}Wr*F3|7PEn_$zq&b#k?C3}r?D)N9qK?(ilo?tC zvf<%sd3mJQex|-rTWF%{E-yR0y3^`FSl0jUHtsNltEJQ`XUfuZ2@j-aS0@aRI@;g^ ztNMb~-jEfNPL>A^ot1ZOcy#D-sPuHlUSX_11gI?Jb%e{{jF>kbKNfG6%kE=`-hrC* ze_e0yXU#lYjp?n#fA`7E%x(vnRs-I@sDY1cM zm}%OIDjQ9r4V@~hEOebZ=eJHOqBYkYkrcGMfjZOvQ-5gl1|PL{4xP5%)80|Gj}%HS z4y#(^2BEfncd7H*@edqXe&&wD1??FhKl|4e2JGk666o`s{DJyShvjj<*Oh44%SA2V z7>!0EAJmzr2$fDo+ec?0Pp z^CWxAJ?%dTwPCA<&$yb9tEZjh zyk&`D2@Q|;YPSXvf&}Sn;fut%b4NX$1A6QgR&KTbQ@<)4eL5I(6mA-_(!QtV+UIv)Y``VXTQCa(7?Y zG-_ki%N}1{WeB;kAP|4XE8naNjwdgQ2u5!m}#m3X;MfHOi% ze1CA`FUjKInGX;!U}aJ=egSoXF>{< z-c}Bje$o!}gM?A!R$;cJVqM&mKkZTTSwwNMP3-P64H%**(Ox*5KwHZcj|R8-wgkLV*d!|7VAc=uUmSEGBNXiH znv|)td0fHBtG!m^JNL988M^luA^JwCRxpLx)kY#5-P~Ida&&g?c)nd;x&W$_}l|) z04^>r7Cr?)KE8L^RD^dykOnD7AXdMw28!TYTXl`QhoKvx^G~osdwY0TH}P0x!jk#bbN%DS%6#99p}- zT<5)-&3=g8Y5fW1q*F-wz-6Ey!?s5Qe`9p>qn0aNq^>Zzb1cTpRoCeLGM=~X5 zXJ=o=_D)U`$L?8#y=XEmMDE?zoMJ+g6P|P!Rjqg?6mu#hu(PU{iV2hZG#4e22Gx7} zFOUFMbdsVl5|8d}udXTUq_>{5Qlig+vu#72jq zRs}!DX{!u1z115J5;5?*i08%=%rE$Yx)~5EE}cnM-evuHs;c9m_!5ttN0p9YVwaKY1Iypi{ zgP}LcpbE+)Gi^6vE&c@()**YI)*&>r{DZN#F>X*b+ z*_(v+qZYx3joxBS3Bqv9Z*6UF?)pJ!!TueEi=G_t52M7Tvo2O90AC^Enx$eW13$<9V0gNho8SxVAHXoR7)T-{A-P_+$baU zM!SCOi+5LNky|NAdN=sZ4L?g3e2GozvdhCXz!uX~f=Iop3bDqLg#ht%-p<`!KPjzm zZqAu-9+ZKUoTDhvGm|o1O-kSU&S@J`uMi zU%s)_9_Q`D0_;|_6qJKuC6&IWk>`o=@g=%-_Gg=ecb5n3TwIsu=Xwp!$e^GOV5tcU z3!9yw`gZvmjUkvJ9K@m|?FKJuN#hC`dkScI)~3 zVHc_^KvRgM6nF-@e|Ye|*uB`!iF`)Riw??DP7|^iqACoDTl7LVk05=Hswm9;4u##B zZF1wh`XGcrWUMNYXm`A4k(Vqi*flIP{rLJtn4k8q)8l62o*Hx{QPsY8#&Cem*TA{Q zwu>YCC3Guuj40T6-Xw;irlV(zj2~2d*FVi)CjLT^k7lj=iB9wc0aVF%s z_5SE|N(@hCm0Yb)nFd@U_8~7PsJ<2WG>e#d0s*0dALA1TP ztqNF^{cK4M(8%QLgwpbZPE>WDPyYRfRo3cjY^Fu87F>W6rvX#T)zvja)OQyq1~P(f zAI-0va1oaZh0z&7vGd&)JdBoi^@Vd^nyv|O=+DdN=sziCw;Cdl&8arkuMC)^lKt2g z{wXk_LyEZAtpBQK#hC=N6&2?(`vpx}!@y8+&+{9`FQnX~nplrwf97-&09FFP0T(;t z-RAEy4_5uX6AcEvciTRt zJuf~;KXN$!AOsLm*^~d)CU?*K+=OIb;fy$DZF@f7-xISS_9U?aNN!iUc z0K4iZkDAG|Qj4uUF!%duZo7(O6h~8!NVIPJ*l&*={km=wHnOai?C~YRIbin$pn7~S zXD!@J11Pu$bboVQ?4vYmg8qQA#b28TWc!+ZoMz}S6Z4>YzCtzfw|6jp<6Qu0(fMsC z`%T^em7B7$WlaO^y9>7yRAqWF34FD@Y`;&+V{OjYyJP;#j1(;hUS=TTu+*HJ%Vaf~ zX}{Qbu=HCPxOjQTY~Lz4N#Hj*X(t3Qh&(tkEBa=i&ofP%1O*yP!;y_KX>Xiv?`&GH z;Y8A|XPOA6+`r<8y5W`#|CHM3e$qulpp{5NI%5>v_QPX6-iPaRW-_XL;~w6j=1U{v(cr zL%=5Ff7Re`&BoSeW0x&`m^mnGm5b3{Mf#twUFpVN2F;>AvsTWEyL=ae=}gHwl?ohT%7ESyK(IR~0XDf~~;eXU5S%FJ{2a{f}EqW~1;3ozy zR<-RbE(_%9d+PkPjepXTb-*tNyb0MLDfB*u0V7O^`Zy^5RuGKBh7(tbKx`n|qC>N_ zebSG$TBVntwhvZ~!}NqcAFmv*b|AZ&p^}mShCT*#KsP}8Z*25B-yQ|57!E$#LgIC(g>;y!L8P^hp2bRciv={vQ|FN zHMmN{L%$?j7*@%>1c6Y4L3|(%g-##SNx12dHBZvv-u?MrhN#H->m9nU#zW~Mcfc4L zP7@{ty;M$p=k%{LI2avNP*kJ?_;`S}tgfz}vY>=E`4TG|+`km?fne?Ur`<5=JC@08 zah{;)n&$fhQp5DKIo**7ugtf$HUntlzb+p?mxG z5&!`z%F4>XSYlyu^zh(jWwmNQO^jh1=r?S1?sBk!XNY=*^Zk@W5kjyLQQepK;k&Sj z6BYc7!ZG=cvx!&!38m=t>e=43#mrhq$Q3OZ^s!WLw$^SwOELr)wQjfPR_R;5K0XQD zhD%izDzUxQz@!3*DwpmE>W^gx7u0m6Y0Ek8Q*&~E_OYmZC9=}>b*gtU%|~*l4HFZA ze^9I1(*b%a@uKT7$I$MmyGz` zb}zU0xao24XOO3-D+n}G@07z53Xrmhb3#+Vw?x3LTWbrb?P9?D&hIZ;?@#-SKbGlm zBmnIMmKJU?l$zyU=CA~Cl*4BNOP2u+3eVwP8-9#w{%VBDrPo+Y9iQV@pjRJL+%X_7BDmK9^S7W?z(6?A_+Mqajuf zWRJJgHTs?Q%kb^n{VJ2U> z@nT@IOEtc;e47H8Bn-TEmgB#R!3gX72DkD<5CUY8M!V~>?8)v9R~okPE}^6IGafEWFv4R0a3-v%j+c* z6Ch>g8MgWv8ym~yoB--9UI#h`hP3WeKZ=IYIe5Iw%6XNHQeMt;jE-dA)%zcCR&>>3+?%)NIv{j);Q)1OZ3U{Ll9H zd`?!3mo3wWGO?DbF}P>UkhU-$IlkisV8$}TRv-!9yx;77m600@x5uf4^VCw{8`ad>k@9Xvx94DrQc*xy(iB*Uu`86$ey0sgewi8~ zDqhq^1JMbKIZR%bC}~``aD{$j$g$;+`ekQe^bQMSF%K@ zD#it(x7rYA1aN*lO(;y|zcP z{``RkwMYH@>G^o?$tni4Km8{6gORMD$Ve3cHv&!~ptNOwP%w9Kw5QGT-w8REY(G;L zpS3|O4=Vc2hJ42fakMJyag^5kpRq=gzEg#o^vyBSg5=jD)r4;b| zv-a9N=(1NU7LTzWqUVfMF3HIE-aGKzOwQjtIY3o9wEtD4<2k}6AX ziW0zXAun*J4Q(va7}EpTXEQUi`1tt4!$VNH=h-Hu^`T7q8a}n}PJU-+Cjx=^dUJAo z{CTgWj>$n5S~ktCRo*r|O$xFv{sBNIhkJo=38u{P?8uiRq%S@oQN^R*y*WD z;ut~d`-;1n=$x$~I1wodw5MI7a0LS4Qz`t3BIEPE=ybeY+HOJVd$I9f6Ak-JyaY@y zht^+*^i2IcMs1)%3tu1t)-|{HH)kj)C~mI`C*I40>RaX;OOxv)z^}>hzI^#2FE4+# zHLL=I#k5>qUfPf6DXFTe0+OYoqT+IkZ;g(fyZhzs#U25zC{Xbm0|SayXDh-LvOcD@ z;m{w9xn+5=EdkR0Sv$i?@YXnA(fQRJyTf8-VQoAP7VK_ps-U)BLJ&De!taon%foqG z*q4+h*>8s8QjU(F=T{(4pjP-2aQYz~+0s!vbC5VB7)R30Nn2Z4!p}?jvO!_Tf_F^4 zsxnj|w9N!10V7@q>C*lUhv9V!wpO{?VC1gOqy5v^3jD8_dq-5)w%%KeC@im(@8^iy z%L7~xz$-|D0Xrse$?qCcY{!x8x31>I`Msg|F<)yZ43K<4FEKDM02(0>b$R*ky`NA) z9v)Y~p5p~%wv&@nt<*309^m{V<3t^TopD=au@RLQuqqA50e!fdaA} z9Ve<9rT34B!$ecAL|kG74-2?k4HJzH4u7=XXgi6aQ5fbKbaWLqGL zTzQ?DkKT>T;)M>@-tOjFaAw{)gc6H zNrPxmB0;fl52s{>G{hE>**2&7J|+BYMfNa`P&mEG+W#^2~wqO-uwVaFxGwO5kkqVEV8wH8}hw^TzJ(9H6DO zwTVZpoqLINJ5^LvnEnkvIX&&`?@s{?PXN1T`@-E$PC@+~EQp6AdGZVwp>a zzgpP0nf4qHOBO@av&;C*^Wf5TaHlx*l+0TykkRaDE%r`pnKLvgw};x5@9^+yTmEkD<)&_9_*_%(S$T- zqfI9~0%~K?-W}~R4aSk-cr9s8pW1-eyG3_Hl{yH`)?(aI+sfPDt9z%m2hURF(5rU@ z79x6DL550r&o9CqDz&h3y(@4X)liD!7(Y}e>kLZrQ>1J z{n~r%N%-jM3D_^tC_0$p4J_|BlQ10=h3jmA%0da zJ3XaK*V^l9Z@30y%K^Ch#W z3om*apUnUN$48tgcKx*Xrlo5^yjT2J1-$i>EoZvaSoO0FoJik;;mC)FhpRj@;A?^B g1AuElq~{6b7h!GZ_(V8PuT_I>-?+CO0T)f6=~ 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 fg8UNt{Q)k@K~NSgskr^`9vWFGCCM6bld%5m0 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 literal 13550 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 Date: Thu, 6 Aug 2026 18:15:44 -0400 Subject: [PATCH 6/9] feat(sports): make the scroll/Vegas card layout configurable Turns the values that were baked into the card into settings, and wires up the layout offsets the schema had been advertising without effect. Still scoped to game_renderer.py and scroll_display*.py, which only scroll and Vegas modes reach; the full-screen scoreboard is untouched. scroll_card grows from three settings to eleven: vs_text the matchup separator -- VS, @, at, v, or blank date_format abbrev | numeric | day_first | numeric_day_first | weekday time_format 12h | 24h show_date draw the date at all show_time draw the start time at all swap_date_time date on top, time along the bottom upcoming_center vs | date_time | none center_gap pin the middle strip, 0 for edge-to-edge logos center_gap_ratio/_min/_max tune the automatic gap customization gains text_color on all six text elements, and customization.layout is now read by this renderer: the x/y offsets for score, date, time, status_text and both logos have been in the schema and the web UI all along, but only sports.py (the full-screen scorebug) consumed them, so nudging them did nothing to the scroll cards. Offsets are read through the same semantics as sports.py _get_layout_offset. On "at" and "@": the away team is drawn in the left slot and the home team in the right, and every score reads away-home, so "A at B" is the correct reading. Verified by holding the away team fixed and changing only the home team -- the left slot stays pixel-identical in all eight plugins. Also restores _logo_cache_key, which the block rewrite dropped while six plugins still called it. Harness 168/168 PASS. football adaptive suite 27/27 (its path needed the new options wired separately, since it returns before the classic code). baseball 126 passed / 4 pre-existing errors, matching baseline. Every setting verified end to end: helper values and the drawn pixels. --- plugins/afl-scoreboard/config_schema.json | 208 +- plugins/afl-scoreboard/game_renderer.py | 248 +- plugins/afl-scoreboard/manifest.json | 2 +- .../baseball-scoreboard/config_schema.json | 4542 +++++++++-------- plugins/baseball-scoreboard/game_renderer.py | 273 +- plugins/baseball-scoreboard/manifest.json | 2 +- .../basketball-scoreboard/config_schema.json | 1119 ++-- .../basketball-scoreboard/game_renderer.py | 248 +- plugins/basketball-scoreboard/manifest.json | 2 +- .../football-scoreboard/config_schema.json | 1195 +++-- plugins/football-scoreboard/game_renderer.py | 271 +- plugins/football-scoreboard/manifest.json | 2 +- plugins/hockey-scoreboard/config_schema.json | 383 +- plugins/hockey-scoreboard/game_renderer.py | 248 +- plugins/hockey-scoreboard/manifest.json | 2 +- .../lacrosse-scoreboard/config_schema.json | 208 +- plugins/lacrosse-scoreboard/game_renderer.py | 248 +- plugins/lacrosse-scoreboard/manifest.json | 2 +- plugins/nrl-scoreboard/config_schema.json | 212 +- plugins/nrl-scoreboard/game_renderer.py | 248 +- plugins/nrl-scoreboard/manifest.json | 2 +- plugins/soccer-scoreboard/config_schema.json | 329 +- plugins/soccer-scoreboard/game_renderer.py | 248 +- plugins/soccer-scoreboard/manifest.json | 2 +- 24 files changed, 6706 insertions(+), 3538 deletions(-) diff --git a/plugins/afl-scoreboard/config_schema.json b/plugins/afl-scoreboard/config_schema.json index 8870b1ba..7d0389a6 100644 --- a/plugins/afl-scoreboard/config_schema.json +++ b/plugins/afl-scoreboard/config_schema.json @@ -13,29 +13,95 @@ "upcoming_center": { "type": "string", "title": "Middle of an Upcoming Card", - "description": "What to show between the two logos before a game starts: VS, or the date and time stacked in the middle. Upcoming games never show a score, since the game has not been played.", + "description": "What to show between the two logos before a game starts. Upcoming games never show a score, since the game has not been played.", "enum": [ "vs", - "date_time" + "date_time", + "none" ], "default": "vs" }, + "vs_text": { + "type": "string", + "title": "Matchup Separator", + "description": "Text drawn between the two teams, e.g. VS, @, at, v. The away team is always on the left and the home team on the right, so \"at\" and \"@\" read correctly as \"away at home\". Leave blank to draw nothing.", + "default": "VS", + "maxLength": 4 + }, "date_format": { "type": "string", "title": "Date Format", - "description": "How to write the date on an upcoming card: abbrev shows \"Sep 19\", numeric shows \"9/19\".", + "description": "How to write the date: abbrev \"Sep 19\", numeric \"9/19\", day_first \"19 Sep\", numeric_day_first \"19/9\", weekday \"Fri Sep 19\".", "enum": [ "abbrev", - "numeric" + "numeric", + "day_first", + "numeric_day_first", + "weekday" ], "default": "abbrev" }, + "time_format": { + "type": "string", + "title": "Time Format", + "description": "12h shows \"7:00PM\"; 24h shows \"19:00\".", + "enum": [ + "12h", + "24h" + ], + "default": "12h" + }, + "show_date": { + "type": "boolean", + "title": "Show Date", + "description": "Draw the date on upcoming cards.", + "default": true + }, + "show_time": { + "type": "boolean", + "title": "Show Time", + "description": "Draw the start time on upcoming cards.", + "default": true + }, + "swap_date_time": { + "type": "boolean", + "title": "Swap Date and Time", + "description": "Put the date on top and the time along the bottom instead of the default.", + "default": false + }, "center_gap": { "type": "integer", "title": "Center Gap", - "description": "Pixels kept clear down the middle of a card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", + "description": "Pixels kept clear down the middle so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. 0 restores the old edge-to-edge logos.", "minimum": 0, "maximum": 64 + }, + "center_gap_ratio": { + "type": "number", + "title": "Center Gap Ratio", + "description": "Fraction of card width used for the centre gap when it is not pinned.", + "minimum": 0.0, + "maximum": 0.6, + "default": 0.28, + "x-advanced": true + }, + "center_gap_min": { + "type": "integer", + "title": "Center Gap Minimum", + "description": "Lower bound in pixels for the scaled centre gap.", + "minimum": 0, + "maximum": 64, + "default": 22, + "x-advanced": true + }, + "center_gap_max": { + "type": "integer", + "title": "Center Gap Maximum", + "description": "Upper bound in pixels for the scaled centre gap.", + "minimum": 0, + "maximum": 96, + "default": 40, + "x-advanced": true } } }, @@ -592,6 +658,25 @@ "maximum": 16, "default": 10, "x-advanced": true + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the score text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -628,6 +713,25 @@ "maximum": 16, "default": 8, "x-advanced": true + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the period text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -664,6 +768,25 @@ "maximum": 16, "default": 8, "x-advanced": true + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the team name on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -700,6 +823,25 @@ "maximum": 16, "default": 6, "x-advanced": true + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the status text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -736,6 +878,25 @@ "maximum": 16, "default": 6, "x-advanced": true + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the detail text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -772,6 +933,25 @@ "maximum": 16, "default": 10, "x-advanced": true + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the rank text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -959,7 +1139,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [0, 255, 0], + "default": [ + 0, + 255, + 0 + ], "x-advanced": true }, "loss_color": { @@ -974,7 +1158,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 0, 0], + "default": [ + 255, + 0, + 0 + ], "x-advanced": true }, "tie_color": { @@ -989,7 +1177,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 200, 0], + "default": [ + 255, + 200, + 0 + ], "x-advanced": true } }, diff --git a/plugins/afl-scoreboard/game_renderer.py b/plugins/afl-scoreboard/game_renderer.py index f1534cc2..84ffc7f9 100644 --- a/plugins/afl-scoreboard/game_renderer.py +++ b/plugins/afl-scoreboard/game_renderer.py @@ -496,12 +496,16 @@ def render_game_card( # Place logos — each centered within a slot on its side; cap at half the card # width so home_slot_start stays non-negative on square/tall displays logo_slot = self._logo_slot_width() - away_x = (logo_slot - away_logo.width) // 2 - away_y = center_y - (away_logo.height // 2) + away_x = ((logo_slot - away_logo.width) // 2 + + self._layout_offset('away_logo', 'x_offset')) + away_y = (center_y - (away_logo.height // 2) + + self._layout_offset('away_logo', 'y_offset')) home_slot_start = self.display_width - logo_slot - home_x = home_slot_start + (logo_slot - home_logo.width) // 2 - home_y = center_y - (home_logo.height // 2) + home_x = (home_slot_start + (logo_slot - home_logo.width) // 2 + + self._layout_offset('home_logo', 'x_offset')) + home_y = (center_y - (home_logo.height // 2) + + self._layout_offset('home_logo', 'y_offset')) # Draw logos main_img.paste(home_logo, (home_x, home_y), home_logo) @@ -510,8 +514,10 @@ def render_game_card( # Draw scores (centered) — only once a game has started. Upcoming games # have no score, so the extractor's 0-0 was pure noise. if game_type in ("live", "recent"): - score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 + score_x = ((self.display_width - score_width) // 2 + + self._layout_offset('score', 'x_offset')) + score_y = ((self.display_height // 2) - 3 + + self._layout_offset('score', 'y_offset')) self._draw_text_with_outline( draw_overlay, score_text, (score_x, score_y), self.fonts['score'], fill=self._score_color_for(game, game_type) @@ -586,22 +592,23 @@ def _draw_recent_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['detail']) # ------------------------------------------------------------------ - # Scroll/Vegas card options -- config["scroll_card"]. + # Scroll/Vegas card options -- config["scroll_card"], plus the shared + # customization.layout offsets and per-element colours. # # These only affect the cards this renderer builds, which are used by # scroll_display.py and scroll_display_legacy.py alone. The full-screen # scorebug is drawn elsewhere and is deliberately left untouched. # ------------------------------------------------------------------ - # Middle strip kept clear of logos so the score / "VS" is never drawn on - # top of them. 0.28 of a 128px card clears "1-2" (30px) with room spare. CENTER_GAP_RATIO: ClassVar[float] = 0.28 - # 22 so "VS" (20px) still clears the logos on the narrowest 64px card. CENTER_GAP_MIN_PX: ClassVar[int] = 22 CENTER_GAP_MAX_PX: ClassVar[int] = 40 _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ) + _WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = ( + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", + ) def _logo_cache_key(self, name: str) -> str: """Cache key scoped to the logo slot. @@ -618,16 +625,58 @@ def _scroll_card_option(self, key: str, default: Any = None) -> Any: return block.get(key) return default + def _layout_offset(self, element: str, axis: str, default: int = 0) -> int: + """X/Y nudge for one element, from customization.layout. + + Same block the full-screen scorebug reads (sports.py + _get_layout_offset), so a nudge configured in the web UI now moves + the element on the scroll/Vegas card too -- previously the schema + advertised these offsets but this renderer ignored them. + """ + try: + layout = (self.config or {}).get("customization", {}).get("layout", {}) + value = (layout.get(element) or {}).get(axis, default) + if isinstance(value, bool): + return default + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + return int(float(value)) + except (TypeError, ValueError): + pass + return default + + def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): + """Per-element text colour from customization..text_color.""" + try: + cfg = (self.config or {}).get("customization", {}).get(element, {}) + value = cfg.get("text_color") + if isinstance(value, (list, tuple)) and len(value) == 3: + return tuple(max(0, min(255, int(c))) for c in value) + if isinstance(value, str) and value.startswith("#") and len(value) == 7: + return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) + except (TypeError, ValueError): + pass + return default + def _center_gap_width(self) -> int: """Width of the middle strip kept clear of logos. - ``scroll_card.center_gap`` overrides it; 0 restores edge-to-edge logos. + ``scroll_card.center_gap`` pins it outright; otherwise it scales with + the card width between the configurable min and max. 0 restores + edge-to-edge logos. """ configured = self._scroll_card_option("center_gap") if isinstance(configured, (int, float)) and configured >= 0: return int(configured) - scaled = round(self.display_width * self.CENTER_GAP_RATIO) - return int(max(self.CENTER_GAP_MIN_PX, min(self.CENTER_GAP_MAX_PX, scaled))) + ratio = self._scroll_card_option("center_gap_ratio", self.CENTER_GAP_RATIO) + low = self._scroll_card_option("center_gap_min", self.CENTER_GAP_MIN_PX) + high = self._scroll_card_option("center_gap_max", self.CENTER_GAP_MAX_PX) + try: + scaled = round(self.display_width * float(ratio)) + return int(max(int(low), min(int(high), scaled))) + except (TypeError, ValueError): + return self.CENTER_GAP_MIN_PX def _logo_slot_width(self) -> int: """Per-side logo slot, leaving the center gap clear. @@ -640,50 +689,124 @@ def _logo_slot_width(self) -> int: return max(8, min(self.display_height, available)) def _upcoming_center_mode(self) -> str: - """What sits in the middle of an upcoming card: 'vs' or 'date_time'.""" + """Middle of an upcoming card: 'vs', 'date_time' or 'none'.""" mode = str(self._scroll_card_option("upcoming_center", "vs") or "vs").lower() - return mode if mode in ("vs", "date_time") else "vs" + return mode if mode in ("vs", "date_time", "none") else "vs" - def _format_game_date(self, date_text: str) -> str: - """Render a date as "Sep 19" (default) or "9/19" (``date_format``).""" + def _vs_text(self) -> str: + """Separator drawn between the teams -- "VS", "@", "at", anything.""" + return str(self._scroll_card_option("vs_text", "VS")) + + def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: + """Format an upcoming card's date per scroll_card.date_format.""" raw = str(date_text or "").strip() - if not raw or self._scroll_card_option("date_format", "abbrev") == "numeric": + if not raw: + return "" + fmt = str(self._scroll_card_option("date_format", "abbrev") or "abbrev") + if fmt == "numeric": return raw parts = raw.replace("-", "/").split("/") - if len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): - month = int(parts[0]) - if 1 <= month <= 12: - return f"{self._MONTH_ABBR[month - 1]} {int(parts[1])}" - return raw + if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): + return raw + month, day = int(parts[0]), int(parts[1]) + if not 1 <= month <= 12: + return raw + name = self._MONTH_ABBR[month - 1] + if fmt == "numeric_day_first": + return f"{day}/{month}" + if fmt == "day_first": + return f"{day} {name}" + if fmt == "weekday": + weekday = self._weekday_for(game) + return f"{weekday} {name} {day}" if weekday else f"{name} {day}" + return f"{name} {day}" + + def _weekday_for(self, game: Optional[Dict]) -> str: + """Weekday abbreviation from the game's start time, or ''.""" + if not game: + return "" + raw = game.get("start_time_utc") or game.get("start_time") + if not raw: + return "" + try: + start = raw if isinstance(raw, datetime) else datetime.fromisoformat( + str(raw).replace("Z", "+00:00")) + return self._WEEKDAY_ABBR[start.astimezone(self._card_tzinfo()).weekday()] + except (ValueError, TypeError): + return "" + + def _card_tzinfo(self): + """Timezone for weekday/24h conversions; falls back to UTC.""" + try: + configured = (self.config or {}).get("timezone") + if configured: + return ZoneInfo(configured) + except Exception: + pass + return timezone.utc + + def _format_game_time(self, time_text: str) -> str: + """Return the time as-is (12h) or converted to 24h.""" + raw = str(time_text or "").strip() + if not raw or str(self._scroll_card_option("time_format", "12h")) != "24h": + return raw + cleaned = raw.upper().replace(" ", "") + meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" + if not meridiem: + return raw + try: + hh, _, mm = cleaned[:-2].partition(":") + hour, minute = int(hh), int(mm or 0) + except ValueError: + return raw + if not (0 <= hour <= 12 and 0 <= minute <= 59): + return raw + hour = hour % 12 + (12 if meridiem == "PM" else 0) + return f"{hour:02d}:{minute:02d}" def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: """Draw the middle of an upcoming card. Never a score: an upcoming game has not started, so the extractor's - 0-0 is noise. Either "VS" (default) or the date and time stacked. + 0-0 is noise. Either the VS text (default), the date and time stacked, + or nothing at all. """ - if self._upcoming_center_mode() == "vs": - vs_text = "VS" + mode = self._upcoming_center_mode() + if mode == "none": + return + + if mode == "vs": + vs_text = self._vs_text() + if not vs_text: + return vs_width = draw.textlength(vs_text, font=self.fonts['score']) - vs_x = (self.display_width - vs_width) // 2 - vs_y = (self.display_height // 2) - 3 + vs_x = (self.display_width - vs_width) // 2 + self._layout_offset('score', 'x_offset') + vs_y = (self.display_height // 2) - 3 + self._layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts['score'] + draw, vs_text, (vs_x, vs_y), self.fonts['score'], + fill=self._element_color('score_text') ) return date_text, time_text = self._upcoming_date_and_time(game) - date_text = self._format_game_date(date_text) - font = self.fonts.get('detail') or self.fonts['time'] - lines = [t for t in (date_text, time_text) if t] + lines = [] + if self._scroll_card_option("show_date", True): + lines.append(self._format_game_date(date_text, game)) + if self._scroll_card_option("show_time", True): + lines.append(self._format_game_time(time_text)) + lines = [t for t in lines if t] if not lines: return + font = self.fonts.get('detail') or self.fonts['time'] line_h = 7 top = (self.display_height // 2) - (len(lines) * line_h) // 2 + top += self._layout_offset('score', 'y_offset') for i, line in enumerate(lines): width = draw.textlength(line, font=font) + x = (self.display_width - width) // 2 + self._layout_offset('score', 'x_offset') self._draw_text_with_outline( - draw, line, ((self.display_width - width) // 2, top + i * line_h), font + draw, line, (x, top + i * line_h), font, + fill=self._element_color('detail_text') ) def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: @@ -694,34 +817,55 @@ def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: ) def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: - """Draw date/time around an upcoming card: time top, date bottom. + """Draw the date and time around an upcoming card. - Skipped when the date and time are stacked in the middle instead -- - drawing both would print them twice. + Time top and date bottom by default; scroll_card.swap_date_time puts + the date on top instead. Skipped when the pair is stacked in the + middle, which would otherwise print them twice. """ - if self._upcoming_center_mode() != "vs": + if self._upcoming_center_mode() == "date_time": return - date_text, time_text = self._upcoming_date_and_time(game) - date_text = self._format_game_date(date_text) - - if time_text: - time_width = draw.textlength(time_text, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 + date_raw, time_raw = self._upcoming_date_and_time(game) + date_text = (self._format_game_date(date_raw, game) + if self._scroll_card_option("show_date", True) else "") + time_text = (self._format_game_time(time_raw) + if self._scroll_card_option("show_time", True) else "") + + if self._scroll_card_option("swap_date_time", False): + top_text, top_el, bottom_text, bottom_el = ( + date_text, 'date', time_text, 'time') + top_font = self.fonts.get('detail') or self.fonts['time'] + bottom_font = self.fonts['time'] + top_color, bottom_color = 'detail_text', 'period_text' + else: + top_text, top_el, bottom_text, bottom_el = ( + time_text, 'time', date_text, 'date') + top_font = self.fonts['time'] + bottom_font = self.fonts.get('detail') or self.fonts['time'] + top_color, bottom_color = 'period_text', 'detail_text' + + if top_text: + top_width = draw.textlength(top_text, font=top_font) + top_x = (self.display_width - top_width) // 2 + self._layout_offset(top_el, 'x_offset') + top_y = 1 + self._layout_offset(top_el, 'y_offset') self._draw_text_with_outline( - draw, time_text, (time_x, 1), self.fonts['time'] + draw, top_text, (top_x, top_y), top_font, + fill=self._element_color(top_color) ) - if date_text: - date_font = self.fonts.get('detail') or self.fonts['time'] - date_width = draw.textlength(date_text, font=date_font) - date_x = (self.display_width - date_width) // 2 + if bottom_text: + bottom_width = draw.textlength(bottom_text, font=bottom_font) + bottom_x = ((self.display_width - bottom_width) // 2 + + self._layout_offset(bottom_el, 'x_offset')) # Measured, not a fixed -7: the detail font is 6px in most plugins - # but 10px in soccer and nrl, where "Sep 19" ran 5px past the card. - date_bottom = draw.textbbox((0, 0), date_text, font=date_font)[3] - date_y = max(0, self.display_height - date_bottom - 1) + # but 10px in soccer and nrl, where "Sep 19" ran past the card. + ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] + bottom_y = (max(0, self.display_height - ink_bottom - 1) + + self._layout_offset(bottom_el, 'y_offset')) self._draw_text_with_outline( - draw, date_text, (date_x, date_y), date_font + draw, bottom_text, (bottom_x, bottom_y), bottom_font, + fill=self._element_color(bottom_color) ) def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None: diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index f3e35566..27102d00 100644 --- a/plugins/afl-scoreboard/manifest.json +++ b/plugins/afl-scoreboard/manifest.json @@ -21,7 +21,7 @@ { "version": "1.5.0", "released": "2026-08-06", - "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged.", + "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged. Adds a fuller set of scroll_card settings: vs_text (VS, @, at, ...), date_format now covering abbrev/numeric/day_first/numeric_day_first/weekday, time_format 12h or 24h, show_date, show_time, swap_date_time, upcoming_center gains a 'none' option, and center_gap_ratio/min/max for the automatic gap. Each customization text element gains text_color, and the customization.layout X/Y offsets are now honoured by the scroll/Vegas card -- the schema advertised them but only the full-screen scoreboard read them before. The away team is drawn on the left and the home team on the right, so \"at\" and \"@\" read correctly as \"away at home\".", "ledmatrix_min_version": "2.0.0" }, { diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index 5ceaa1ae..23079f3f 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -1,9 +1,9 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Baseball Scoreboard Plugin Configuration", - "description": "Configuration schema for the Baseball Scoreboard plugin - displays live, recent, and upcoming MLB, MiLB, and NCAA Baseball games", - "type": "object", - "properties": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Baseball Scoreboard Plugin Configuration", + "description": "Configuration schema for the Baseball Scoreboard plugin - displays live, recent, and upcoming MLB, MiLB, and NCAA Baseball games", + "type": "object", + "properties": { "scroll_card": { "type": "object", "title": "Scroll & Vegas Card Layout", @@ -13,2172 +13,2496 @@ "upcoming_center": { "type": "string", "title": "Middle of an Upcoming Card", - "description": "What to show between the two logos before a game starts: VS, or the date and time stacked in the middle. Upcoming games never show a score, since the game has not been played.", + "description": "What to show between the two logos before a game starts. Upcoming games never show a score, since the game has not been played.", "enum": [ "vs", - "date_time" + "date_time", + "none" ], "default": "vs" }, + "vs_text": { + "type": "string", + "title": "Matchup Separator", + "description": "Text drawn between the two teams, e.g. VS, @, at, v. The away team is always on the left and the home team on the right, so \"at\" and \"@\" read correctly as \"away at home\". Leave blank to draw nothing.", + "default": "VS", + "maxLength": 4 + }, "date_format": { "type": "string", "title": "Date Format", - "description": "How to write the date on an upcoming card: abbrev shows \"Sep 19\", numeric shows \"9/19\".", + "description": "How to write the date: abbrev \"Sep 19\", numeric \"9/19\", day_first \"19 Sep\", numeric_day_first \"19/9\", weekday \"Fri Sep 19\".", "enum": [ "abbrev", - "numeric" + "numeric", + "day_first", + "numeric_day_first", + "weekday" ], "default": "abbrev" }, + "time_format": { + "type": "string", + "title": "Time Format", + "description": "12h shows \"7:00PM\"; 24h shows \"19:00\".", + "enum": [ + "12h", + "24h" + ], + "default": "12h" + }, + "show_date": { + "type": "boolean", + "title": "Show Date", + "description": "Draw the date on upcoming cards.", + "default": true + }, + "show_time": { + "type": "boolean", + "title": "Show Time", + "description": "Draw the start time on upcoming cards.", + "default": true + }, + "swap_date_time": { + "type": "boolean", + "title": "Swap Date and Time", + "description": "Put the date on top and the time along the bottom instead of the default.", + "default": false + }, "center_gap": { "type": "integer", "title": "Center Gap", - "description": "Pixels kept clear down the middle of a card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", + "description": "Pixels kept clear down the middle so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. 0 restores the old edge-to-edge logos.", "minimum": 0, "maximum": 64 + }, + "center_gap_ratio": { + "type": "number", + "title": "Center Gap Ratio", + "description": "Fraction of card width used for the centre gap when it is not pinned.", + "minimum": 0.0, + "maximum": 0.6, + "default": 0.28, + "x-advanced": true + }, + "center_gap_min": { + "type": "integer", + "title": "Center Gap Minimum", + "description": "Lower bound in pixels for the scaled centre gap.", + "minimum": 0, + "maximum": 64, + "default": 22, + "x-advanced": true + }, + "center_gap_max": { + "type": "integer", + "title": "Center Gap Maximum", + "description": "Upper bound in pixels for the scaled centre gap.", + "minimum": 0, + "maximum": 96, + "default": 40, + "x-advanced": true } } }, + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable or disable the baseball scoreboard plugin" + }, + "display_duration": { + "type": "number", + "default": 30, + "minimum": 5, + "maximum": 300, + "description": "Duration in seconds for the display controller to show this plugin mode before rotating to next plugin" + }, + "update_interval": { + "x-advanced": true, + "type": "integer", + "default": 3600, + "minimum": 30, + "maximum": 86400, + "description": "How often to fetch new data in seconds" + }, + "game_display_duration": { + "type": "number", + "default": 15, + "minimum": 3, + "maximum": 60, + "description": "Duration in seconds to show each individual game before rotating to the next game within the same mode" + }, + "timezone": { + "x-advanced": true, + "type": "string", + "default": "", + "description": "IANA timezone used to display event start times (e.g. America/Chicago). Leave blank to follow the LEDMatrix global timezone, or the system timezone if none is set. A bare \"UTC\" here is treated as a leftover from the old write-back bug and ignored when your global or system timezone disagrees \u2014 use \"Etc/UTC\" if you really want UTC." + }, + "mlb": { + "type": "object", + "title": "MLB Settings", + "description": "Configuration for MLB games", + "properties": { "enabled": { - "type": "boolean", - "default": true, - "description": "Enable or disable the baseball scoreboard plugin" - }, - "display_duration": { - "type": "number", - "default": 30, - "minimum": 5, - "maximum": 300, - "description": "Duration in seconds for the display controller to show this plugin mode before rotating to next plugin" - }, - "update_interval": { - "x-advanced": true, - "type": "integer", - "default": 3600, - "minimum": 30, - "maximum": 86400, - "description": "How often to fetch new data in seconds" - }, - "game_display_duration": { - "type": "number", - "default": 15, - "minimum": 3, - "maximum": 60, - "description": "Duration in seconds to show each individual game before rotating to the next game within the same mode" - }, - "timezone": { - "x-advanced": true, - "type": "string", - "default": "", - "description": "IANA timezone used to display event start times (e.g. America/Chicago). Leave blank to follow the LEDMatrix global timezone, or the system timezone if none is set. A bare \"UTC\" here is treated as a leftover from the old write-back bug and ignored when your global or system timezone disagrees \u2014 use \"Etc/UTC\" if you really want UTC." - }, - "mlb": { - "type": "object", - "title": "MLB Settings", - "description": "Configuration for MLB games", - "properties": { - "enabled": { - "type": "boolean", - "default": true, - "description": "Enable MLB games" - }, - "favorite_teams": { - "type": "array", - "items": { - "type": "string" - }, - "default": [], - "uniqueItems": true, - "maxItems": 30, - "description": "List of favorite MLB team abbreviations (e.g., NYY, BOS, LAD). Use 2-3 letter codes." - }, - "exclude_teams": { - "x-advanced": true, - "type": "array", - "items": { - "type": "string" - }, - "default": [], - "uniqueItems": true, - "maxItems": 30, - "description": "Team abbreviations to always hide from the live rotation and recent/final scores (e.g., to avoid spoilers if you're watching a game delayed). Takes priority over favorite_teams and every other filtering setting." - }, - "display_modes": { - "type": "object", - "title": "Display Modes", - "description": "Control which game types to show", - "properties": { - "show_live": { - "type": "boolean", - "default": true, - "description": "Show live MLB games" - }, - "show_recent": { - "type": "boolean", - "default": true, - "description": "Show recently completed MLB games" - }, - "show_upcoming": { - "type": "boolean", - "default": true, - "description": "Show upcoming MLB games" - }, - "live_display_mode": { - "x-advanced": true, - "type": "string", - "enum": [ - "switch", - "scroll" - ], - "default": "switch", - "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" - }, - "recent_display_mode": { - "x-advanced": true, - "type": "string", - "enum": [ - "switch", - "scroll" - ], - "default": "switch", - "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" - }, - "upcoming_display_mode": { - "x-advanced": true, - "type": "string", - "enum": [ - "switch", - "scroll" - ], - "default": "switch", - "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" - } - } - }, - "scroll_settings": { - "type": "object", - "title": "Scroll Settings", - "description": "Settings for scroll display mode (when display mode is set to 'scroll')", - "properties": { - "scroll_speed": { - "x-advanced": true, - "type": "number", - "default": 50.0, - "minimum": 1.0, - "maximum": 200.0, - "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." - }, - "scroll_delay": { - "x-advanced": true, - "type": "number", - "default": 0.01, - "minimum": 0.001, - "maximum": 0.1, - "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." - }, - "gap_between_games": { - "x-advanced": true, - "type": "integer", - "default": 48, - "minimum": 8, - "maximum": 128, - "description": "Gap in pixels between game cards when scrolling" - }, - "show_league_separators": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "Show league icons (MLB shield, NCAA logos) between different leagues" - }, - "dynamic_duration": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "Automatically calculate display duration based on content width" - }, - "game_card_width": { - "x-advanced": true, - "type": "integer", - "default": 128, - "minimum": 32, - "maximum": 512, - "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." - } - } - }, - "live_priority": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Give live games priority over other modes. When enabled, live games will interrupt the normal mode rotation and be displayed immediately when available." - }, - "live_game_duration": { - "x-advanced": true, - "type": "integer", - "default": 30, - "minimum": 10, - "maximum": 120, - "description": "Duration in seconds to display each live game before rotating to the next. When a separate non-favorite duration is set, this applies to games with a favorite team; it applies to ALL live games when no favorite teams are configured." - }, - "non_favorite_live_game_duration": { - "x-advanced": true, - "type": "integer", - "default": 0, - "minimum": 0, - "maximum": 120, - "description": "Duration in seconds for live games that do NOT involve a favorite team. Only applies when favorite teams are set AND non-favorite live games are shown ('show_favorite_teams_only' off, or 'show_all_live' on). 0 (default) = use live_game_duration for every live game (no change)." - }, - "recent_game_duration": { - "x-advanced": true, - "type": "number", - "default": 15, - "minimum": 10, - "maximum": 120, - "description": "Duration in seconds to show each recent game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." - }, - "upcoming_game_duration": { - "x-advanced": true, - "type": "number", - "default": 15, - "minimum": 10, - "maximum": 120, - "description": "Duration in seconds to show each upcoming game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." - }, - "live_update_interval": { - "x-advanced": true, - "type": "integer", - "default": 30, - "minimum": 5, - "maximum": 300, - "description": "How often to update live game data (seconds)" - }, - "update_interval_seconds": { - "x-advanced": true, - "type": "integer", - "default": 3600, - "minimum": 30, - "maximum": 86400, - "description": "How often to fetch new data for this league (seconds)" - }, - "game_limits": { - "type": "object", - "title": "Game Limits", - "description": "Control how many games to show", - "properties": { - "recent_games_to_show": { - "type": "integer", - "default": 5, - "minimum": 1, - "maximum": 20, - "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." - }, - "upcoming_games_to_show": { - "type": "integer", - "default": 1, - "minimum": 1, - "maximum": 20, - "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." - } - } - }, - "display_options": { - "type": "object", - "title": "Display Options", - "description": "Additional information to show", - "properties": { - "show_records": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Show team records (wins-losses)" - }, - "show_ranking": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Show team rankings (when available)" - }, - "show_odds": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "Show betting odds" - }, - "show_series_summary": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Show series summary information" - }, - "show_pitcher_batter": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Periodically show a dedicated screen with the current pitcher and batter during a live at-bat (requires an extra per-game data fetch)" - }, - "show_last_play": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Show a short code for the most recently completed play (1B, HR, K, BB, etc.) on the pitcher/batter screen" - }, - "show_player_card": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Periodically show a full player card for the current batter (and optionally pitcher): headshot, jersey number, position, bat/throw, and season stats (AVG/HR/RBI for hitters, ERA/W-L/K for pitchers). Requires an extra ESPN athlete lookup; not available for MiLB. Configure look/timing under Customization > Player Card" - }, - "show_traditional_scoreboard": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Periodically show a full-screen traditional ballpark scoreboard: inning-by-inning line score, R/H/E, and an At Bat panel with ball/strike/out indicators" - } - } - }, - "filtering": { - "type": "object", - "title": "Filtering Options", - "description": "Control which teams are shown", - "properties": { - "show_favorite_teams_only": { - "type": "boolean", - "default": true, - "description": "Only show games from favorite teams" - }, - "show_all_live": { - "type": "boolean", - "default": false, - "description": "Show all live games, not just favorites" - }, - "favorite_live_boost": { - "x-advanced": true, - "type": "integer", - "default": 2, - "minimum": 1, - "maximum": 5, - "description": "How many turns your favorite team's live game gets in the rotation for every 1 turn other live games get. Your favorite's game is also always queued first whenever the live rotation refreshes. Set to 1 for even rotation." - } - } - }, - "mode_durations": { - "type": "object", - "title": "Mode Duration Overrides", - "description": "Override how long each mode displays before rotating", - "properties": { - "recent_mode_duration": { - "x-advanced": true, - "type": [ - "number", - "null" - ], - "default": null, - "minimum": 10, - "maximum": 600, - "description": "Override display duration for recent games mode (seconds). Null uses default." - }, - "upcoming_mode_duration": { - "x-advanced": true, - "type": [ - "number", - "null" - ], - "default": null, - "minimum": 10, - "maximum": 600, - "description": "Override display duration for upcoming games mode (seconds). Null uses default." - }, - "live_mode_duration": { - "x-advanced": true, - "type": [ - "number", - "null" - ], - "default": null, - "minimum": 10, - "maximum": 600, - "description": "Override display duration for live games mode (seconds). Null uses default." - } - } - }, - "dynamic_duration": { - "type": "object", - "title": "MLB Dynamic Duration Settings", - "description": "Configure dynamic duration settings for MLB games.", - "properties": { - "enabled": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Enable dynamic duration for MLB games" - }, - "min_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 10, - "maximum": 300, - "default": 30, - "description": "Minimum total duration in seconds for this mode, even if few games are available. Ensures the mode stays visible long enough." - }, - "max_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 60, - "maximum": 600, - "description": "Maximum total duration in seconds for this mode, even if many games are available." - }, - "modes": { - "type": "object", - "title": "Per-Mode Settings for MLB", - "description": "Configure dynamic duration for specific MLB modes", - "properties": { - "live": { - "type": "object", - "properties": { - "enabled": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Enable dynamic duration for MLB live games" - }, - "min_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 10, - "maximum": 300, - "description": "Minimum duration for MLB live mode" - }, - "max_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 60, - "maximum": 600, - "description": "Max duration for MLB live games" - } - } - }, - "recent": { - "type": "object", - "properties": { - "enabled": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Enable dynamic duration for MLB recent games" - }, - "min_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 10, - "maximum": 300, - "description": "Minimum duration for MLB recent mode" - }, - "max_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 60, - "maximum": 600, - "description": "Max duration for MLB recent games" - } - } - }, - "upcoming": { - "type": "object", - "properties": { - "enabled": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Enable dynamic duration for MLB upcoming games" - }, - "min_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 10, - "maximum": 300, - "description": "Minimum duration for MLB upcoming mode" - }, - "max_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 60, - "maximum": 600, - "description": "Max duration for MLB upcoming games" - } - } - } - } - } - } - } + "type": "boolean", + "default": true, + "description": "Enable MLB games" + }, + "favorite_teams": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "uniqueItems": true, + "maxItems": 30, + "description": "List of favorite MLB team abbreviations (e.g., NYY, BOS, LAD). Use 2-3 letter codes." + }, + "exclude_teams": { + "x-advanced": true, + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "uniqueItems": true, + "maxItems": 30, + "description": "Team abbreviations to always hide from the live rotation and recent/final scores (e.g., to avoid spoilers if you're watching a game delayed). Takes priority over favorite_teams and every other filtering setting." + }, + "display_modes": { + "type": "object", + "title": "Display Modes", + "description": "Control which game types to show", + "properties": { + "show_live": { + "type": "boolean", + "default": true, + "description": "Show live MLB games" + }, + "show_recent": { + "type": "boolean", + "default": true, + "description": "Show recently completed MLB games" + }, + "show_upcoming": { + "type": "boolean", + "default": true, + "description": "Show upcoming MLB games" + }, + "live_display_mode": { + "x-advanced": true, + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "recent_display_mode": { + "x-advanced": true, + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "upcoming_display_mode": { + "x-advanced": true, + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } + } }, - "milb": { - "type": "object", - "title": "MiLB Settings", - "description": "Configuration for MiLB (Minor League Baseball) games", - "properties": { - "enabled": { - "type": "boolean", - "default": false, - "description": "Enable MiLB games" - }, - "favorite_teams": { - "type": "array", - "items": { - "type": "string" + "scroll_settings": { + "type": "object", + "title": "Scroll Settings", + "description": "Settings for scroll display mode (when display mode is set to 'scroll')", + "properties": { + "scroll_speed": { + "x-advanced": true, + "type": "number", + "default": 50.0, + "minimum": 1.0, + "maximum": 200.0, + "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." + }, + "scroll_delay": { + "x-advanced": true, + "type": "number", + "default": 0.01, + "minimum": 0.001, + "maximum": 0.1, + "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." + }, + "gap_between_games": { + "x-advanced": true, + "type": "integer", + "default": 48, + "minimum": 8, + "maximum": 128, + "description": "Gap in pixels between game cards when scrolling" + }, + "show_league_separators": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Show league icons (MLB shield, NCAA logos) between different leagues" + }, + "dynamic_duration": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "x-advanced": true, + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "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." + } + } + }, + "live_priority": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Give live games priority over other modes. When enabled, live games will interrupt the normal mode rotation and be displayed immediately when available." + }, + "live_game_duration": { + "x-advanced": true, + "type": "integer", + "default": 30, + "minimum": 10, + "maximum": 120, + "description": "Duration in seconds to display each live game before rotating to the next. When a separate non-favorite duration is set, this applies to games with a favorite team; it applies to ALL live games when no favorite teams are configured." + }, + "non_favorite_live_game_duration": { + "x-advanced": true, + "type": "integer", + "default": 0, + "minimum": 0, + "maximum": 120, + "description": "Duration in seconds for live games that do NOT involve a favorite team. Only applies when favorite teams are set AND non-favorite live games are shown ('show_favorite_teams_only' off, or 'show_all_live' on). 0 (default) = use live_game_duration for every live game (no change)." + }, + "recent_game_duration": { + "x-advanced": true, + "type": "number", + "default": 15, + "minimum": 10, + "maximum": 120, + "description": "Duration in seconds to show each recent game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." + }, + "upcoming_game_duration": { + "x-advanced": true, + "type": "number", + "default": 15, + "minimum": 10, + "maximum": 120, + "description": "Duration in seconds to show each upcoming game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." + }, + "live_update_interval": { + "x-advanced": true, + "type": "integer", + "default": 30, + "minimum": 5, + "maximum": 300, + "description": "How often to update live game data (seconds)" + }, + "update_interval_seconds": { + "x-advanced": true, + "type": "integer", + "default": 3600, + "minimum": 30, + "maximum": 86400, + "description": "How often to fetch new data for this league (seconds)" + }, + "game_limits": { + "type": "object", + "title": "Game Limits", + "description": "Control how many games to show", + "properties": { + "recent_games_to_show": { + "type": "integer", + "default": 5, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + }, + "upcoming_games_to_show": { + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + } + } + }, + "display_options": { + "type": "object", + "title": "Display Options", + "description": "Additional information to show", + "properties": { + "show_records": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Show team records (wins-losses)" + }, + "show_ranking": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Show team rankings (when available)" + }, + "show_odds": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Show betting odds" + }, + "show_series_summary": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Show series summary information" + }, + "show_pitcher_batter": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Periodically show a dedicated screen with the current pitcher and batter during a live at-bat (requires an extra per-game data fetch)" + }, + "show_last_play": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Show a short code for the most recently completed play (1B, HR, K, BB, etc.) on the pitcher/batter screen" + }, + "show_player_card": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Periodically show a full player card for the current batter (and optionally pitcher): headshot, jersey number, position, bat/throw, and season stats (AVG/HR/RBI for hitters, ERA/W-L/K for pitchers). Requires an extra ESPN athlete lookup; not available for MiLB. Configure look/timing under Customization > Player Card" + }, + "show_traditional_scoreboard": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Periodically show a full-screen traditional ballpark scoreboard: inning-by-inning line score, R/H/E, and an At Bat panel with ball/strike/out indicators" + } + } + }, + "filtering": { + "type": "object", + "title": "Filtering Options", + "description": "Control which teams are shown", + "properties": { + "show_favorite_teams_only": { + "type": "boolean", + "default": true, + "description": "Only show games from favorite teams" + }, + "show_all_live": { + "type": "boolean", + "default": false, + "description": "Show all live games, not just favorites" + }, + "favorite_live_boost": { + "x-advanced": true, + "type": "integer", + "default": 2, + "minimum": 1, + "maximum": 5, + "description": "How many turns your favorite team's live game gets in the rotation for every 1 turn other live games get. Your favorite's game is also always queued first whenever the live rotation refreshes. Set to 1 for even rotation." + } + } + }, + "mode_durations": { + "type": "object", + "title": "Mode Duration Overrides", + "description": "Override how long each mode displays before rotating", + "properties": { + "recent_mode_duration": { + "x-advanced": true, + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for recent games mode (seconds). Null uses default." + }, + "upcoming_mode_duration": { + "x-advanced": true, + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for upcoming games mode (seconds). Null uses default." + }, + "live_mode_duration": { + "x-advanced": true, + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for live games mode (seconds). Null uses default." + } + } + }, + "dynamic_duration": { + "type": "object", + "title": "MLB Dynamic Duration Settings", + "description": "Configure dynamic duration settings for MLB games.", + "properties": { + "enabled": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for MLB games" + }, + "min_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 10, + "maximum": 300, + "default": 30, + "description": "Minimum total duration in seconds for this mode, even if few games are available. Ensures the mode stays visible long enough." + }, + "max_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum total duration in seconds for this mode, even if many games are available." + }, + "modes": { + "type": "object", + "title": "Per-Mode Settings for MLB", + "description": "Configure dynamic duration for specific MLB modes", + "properties": { + "live": { + "type": "object", + "properties": { + "enabled": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for MLB live games" }, - "default": [], - "uniqueItems": true, - "maxItems": 120, - "description": "List of favorite MiLB team abbreviations (e.g., DUR, SWB). Use 2-4 letter codes." - }, - "exclude_teams": { - "x-advanced": true, - "type": "array", - "items": { - "type": "string" + "min_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for MLB live mode" }, - "default": [], - "uniqueItems": true, - "maxItems": 120, - "description": "Team abbreviations to always hide from the live rotation and recent/final scores (e.g., to avoid spoilers if you're watching a game delayed). Takes priority over favorite_teams and every other filtering setting." - }, - "display_modes": { - "type": "object", - "title": "Display Modes", - "description": "Control which game types to show", - "properties": { - "show_live": { - "type": "boolean", - "default": true, - "description": "Show live MiLB games" - }, - "show_recent": { - "type": "boolean", - "default": true, - "description": "Show recently completed MiLB games" - }, - "show_upcoming": { - "type": "boolean", - "default": true, - "description": "Show upcoming MiLB games" - }, - "live_display_mode": { - "x-advanced": true, - "type": "string", - "enum": [ - "switch", - "scroll" - ], - "default": "switch", - "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" - }, - "recent_display_mode": { - "x-advanced": true, - "type": "string", - "enum": [ - "switch", - "scroll" - ], - "default": "switch", - "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" - }, - "upcoming_display_mode": { - "x-advanced": true, - "type": "string", - "enum": [ - "switch", - "scroll" - ], - "default": "switch", - "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" - } + "max_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for MLB live games" } + } }, - "scroll_settings": { - "type": "object", - "title": "Scroll Settings", - "description": "Settings for scroll display mode (when display mode is set to 'scroll')", - "properties": { - "scroll_speed": { - "x-advanced": true, - "type": "number", - "default": 50.0, - "minimum": 1.0, - "maximum": 200.0, - "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." - }, - "scroll_delay": { - "x-advanced": true, - "type": "number", - "default": 0.01, - "minimum": 0.001, - "maximum": 0.1, - "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." - }, - "gap_between_games": { - "x-advanced": true, - "type": "integer", - "default": 48, - "minimum": 8, - "maximum": 128, - "description": "Gap in pixels between game cards when scrolling" - }, - "show_league_separators": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "Show league icons between different leagues" - }, - "dynamic_duration": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "Automatically calculate display duration based on content width" - }, - "game_card_width": { - "x-advanced": true, - "type": "integer", - "default": 128, - "minimum": 32, - "maximum": 512, - "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." - } - } - }, - "live_priority": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Give live games priority over other modes. When enabled, live games will interrupt the normal mode rotation and be displayed immediately when available." - }, - "live_game_duration": { - "x-advanced": true, - "type": "integer", - "default": 30, - "minimum": 10, - "maximum": 120, - "description": "Duration in seconds to display each live game before rotating to the next. When a separate non-favorite duration is set, this applies to games with a favorite team; it applies to ALL live games when no favorite teams are configured." - }, - "non_favorite_live_game_duration": { - "x-advanced": true, - "type": "integer", - "default": 0, - "minimum": 0, - "maximum": 120, - "description": "Duration in seconds for live games that do NOT involve a favorite team. Only applies when favorite teams are set AND non-favorite live games are shown ('show_favorite_teams_only' off, or 'show_all_live' on). 0 (default) = use live_game_duration for every live game (no change)." - }, - "recent_game_duration": { - "x-advanced": true, - "type": "number", - "default": 15, - "minimum": 10, - "maximum": 120, - "description": "Duration in seconds to show each recent game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." - }, - "upcoming_game_duration": { - "x-advanced": true, - "type": "number", - "default": 15, - "minimum": 10, - "maximum": 120, - "description": "Duration in seconds to show each upcoming game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." - }, - "live_update_interval": { - "x-advanced": true, - "type": "integer", - "default": 30, - "minimum": 5, - "maximum": 300, - "description": "How often to update live game data (seconds)" - }, - "update_interval_seconds": { - "x-advanced": true, - "type": "integer", - "default": 3600, - "minimum": 30, - "maximum": 86400, - "description": "How often to fetch new data for this league (seconds)" - }, - "game_limits": { - "type": "object", - "title": "Game Limits", - "description": "Control how many games to show", - "properties": { - "recent_games_to_show": { - "type": "integer", - "default": 1, - "minimum": 1, - "maximum": 20, - "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." - }, - "upcoming_games_to_show": { - "type": "integer", - "default": 1, - "minimum": 1, - "maximum": 20, - "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." - } - } - }, - "display_options": { - "type": "object", - "title": "Display Options", - "description": "Additional information to show", - "properties": { - "show_records": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Show team records (wins-losses)" - }, - "show_ranking": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Show team rankings (when available)" - }, - "show_odds": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Show betting odds" - }, - "show_series_summary": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Show series summary information" - } - } - }, - "filtering": { - "type": "object", - "title": "Filtering Options", - "description": "Control which teams are shown", - "properties": { - "show_favorite_teams_only": { - "type": "boolean", - "default": true, - "description": "Only show games from favorite teams" - }, - "show_all_live": { - "type": "boolean", - "default": false, - "description": "Show all live games, not just favorites" - }, - "favorite_live_boost": { - "x-advanced": true, - "type": "integer", - "default": 2, - "minimum": 1, - "maximum": 5, - "description": "How many turns your favorite team's live game gets in the rotation for every 1 turn other live games get. Your favorite's game is also always queued first whenever the live rotation refreshes. Set to 1 for even rotation." - } - } - }, - "mode_durations": { - "type": "object", - "title": "Mode Duration Overrides", - "description": "Override how long each mode displays before rotating", - "properties": { - "recent_mode_duration": { - "x-advanced": true, - "type": [ - "number", - "null" - ], - "default": null, - "minimum": 10, - "maximum": 600, - "description": "Override display duration for recent games mode (seconds). Null uses default." - }, - "upcoming_mode_duration": { - "x-advanced": true, - "type": [ - "number", - "null" - ], - "default": null, - "minimum": 10, - "maximum": 600, - "description": "Override display duration for upcoming games mode (seconds). Null uses default." - }, - "live_mode_duration": { - "x-advanced": true, - "type": [ - "number", - "null" - ], - "default": null, - "minimum": 10, - "maximum": 600, - "description": "Override display duration for live games mode (seconds). Null uses default." - } + "recent": { + "type": "object", + "properties": { + "enabled": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for MLB recent games" + }, + "min_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for MLB recent mode" + }, + "max_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for MLB recent games" } + } }, - "dynamic_duration": { - "type": "object", - "title": "MiLB Dynamic Duration Settings", - "description": "Configure dynamic duration settings for MiLB games.", - "properties": { - "enabled": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Enable dynamic duration for MiLB games" - }, - "min_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 10, - "maximum": 300, - "default": 30, - "description": "Minimum total duration in seconds for this mode, even if few games are available. Ensures the mode stays visible long enough." - }, - "max_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 60, - "maximum": 600, - "description": "Maximum total duration in seconds for this mode, even if many games are available." - }, - "modes": { - "type": "object", - "title": "Per-Mode Settings for MiLB", - "description": "Configure dynamic duration for specific MiLB modes", - "properties": { - "live": { - "type": "object", - "properties": { - "enabled": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Enable dynamic duration for MiLB live games" - }, - "min_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 10, - "maximum": 300, - "description": "Minimum duration for MiLB live mode" - }, - "max_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 60, - "maximum": 600, - "description": "Max duration for MiLB live games" - } - } - }, - "recent": { - "type": "object", - "properties": { - "enabled": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Enable dynamic duration for MiLB recent games" - }, - "min_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 10, - "maximum": 300, - "description": "Minimum duration for MiLB recent mode" - }, - "max_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 60, - "maximum": 600, - "description": "Max duration for MiLB recent games" - } - } - }, - "upcoming": { - "type": "object", - "properties": { - "enabled": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Enable dynamic duration for MiLB upcoming games" - }, - "min_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 10, - "maximum": 300, - "description": "Minimum duration for MiLB upcoming mode" - }, - "max_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 60, - "maximum": 600, - "description": "Max duration for MiLB upcoming games" - } - } - } - } - } + "upcoming": { + "type": "object", + "properties": { + "enabled": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for MLB upcoming games" + }, + "min_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for MLB upcoming mode" + }, + "max_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for MLB upcoming games" } + } } + } } + } + } + } + }, + "milb": { + "type": "object", + "title": "MiLB Settings", + "description": "Configuration for MiLB (Minor League Baseball) games", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable MiLB games" }, - "ncaa_baseball": { - "type": "object", - "title": "NCAA Baseball Settings", - "description": "Configuration for NCAA Baseball games", - "properties": { - "enabled": { - "type": "boolean", - "default": false, - "description": "Enable NCAA Baseball games" - }, - "favorite_teams": { - "type": "array", - "items": { - "type": "string" + "favorite_teams": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "uniqueItems": true, + "maxItems": 120, + "description": "List of favorite MiLB team abbreviations (e.g., DUR, SWB). Use 2-4 letter codes." + }, + "exclude_teams": { + "x-advanced": true, + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "uniqueItems": true, + "maxItems": 120, + "description": "Team abbreviations to always hide from the live rotation and recent/final scores (e.g., to avoid spoilers if you're watching a game delayed). Takes priority over favorite_teams and every other filtering setting." + }, + "display_modes": { + "type": "object", + "title": "Display Modes", + "description": "Control which game types to show", + "properties": { + "show_live": { + "type": "boolean", + "default": true, + "description": "Show live MiLB games" + }, + "show_recent": { + "type": "boolean", + "default": true, + "description": "Show recently completed MiLB games" + }, + "show_upcoming": { + "type": "boolean", + "default": true, + "description": "Show upcoming MiLB games" + }, + "live_display_mode": { + "x-advanced": true, + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "recent_display_mode": { + "x-advanced": true, + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "upcoming_display_mode": { + "x-advanced": true, + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + } + } + }, + "scroll_settings": { + "type": "object", + "title": "Scroll Settings", + "description": "Settings for scroll display mode (when display mode is set to 'scroll')", + "properties": { + "scroll_speed": { + "x-advanced": true, + "type": "number", + "default": 50.0, + "minimum": 1.0, + "maximum": 200.0, + "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." + }, + "scroll_delay": { + "x-advanced": true, + "type": "number", + "default": 0.01, + "minimum": 0.001, + "maximum": 0.1, + "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." + }, + "gap_between_games": { + "x-advanced": true, + "type": "integer", + "default": 48, + "minimum": 8, + "maximum": 128, + "description": "Gap in pixels between game cards when scrolling" + }, + "show_league_separators": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Show league icons between different leagues" + }, + "dynamic_duration": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "x-advanced": true, + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "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." + } + } + }, + "live_priority": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Give live games priority over other modes. When enabled, live games will interrupt the normal mode rotation and be displayed immediately when available." + }, + "live_game_duration": { + "x-advanced": true, + "type": "integer", + "default": 30, + "minimum": 10, + "maximum": 120, + "description": "Duration in seconds to display each live game before rotating to the next. When a separate non-favorite duration is set, this applies to games with a favorite team; it applies to ALL live games when no favorite teams are configured." + }, + "non_favorite_live_game_duration": { + "x-advanced": true, + "type": "integer", + "default": 0, + "minimum": 0, + "maximum": 120, + "description": "Duration in seconds for live games that do NOT involve a favorite team. Only applies when favorite teams are set AND non-favorite live games are shown ('show_favorite_teams_only' off, or 'show_all_live' on). 0 (default) = use live_game_duration for every live game (no change)." + }, + "recent_game_duration": { + "x-advanced": true, + "type": "number", + "default": 15, + "minimum": 10, + "maximum": 120, + "description": "Duration in seconds to show each recent game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." + }, + "upcoming_game_duration": { + "x-advanced": true, + "type": "number", + "default": 15, + "minimum": 10, + "maximum": 120, + "description": "Duration in seconds to show each upcoming game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." + }, + "live_update_interval": { + "x-advanced": true, + "type": "integer", + "default": 30, + "minimum": 5, + "maximum": 300, + "description": "How often to update live game data (seconds)" + }, + "update_interval_seconds": { + "x-advanced": true, + "type": "integer", + "default": 3600, + "minimum": 30, + "maximum": 86400, + "description": "How often to fetch new data for this league (seconds)" + }, + "game_limits": { + "type": "object", + "title": "Game Limits", + "description": "Control how many games to show", + "properties": { + "recent_games_to_show": { + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + }, + "upcoming_games_to_show": { + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + } + } + }, + "display_options": { + "type": "object", + "title": "Display Options", + "description": "Additional information to show", + "properties": { + "show_records": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Show team records (wins-losses)" + }, + "show_ranking": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Show team rankings (when available)" + }, + "show_odds": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Show betting odds" + }, + "show_series_summary": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Show series summary information" + } + } + }, + "filtering": { + "type": "object", + "title": "Filtering Options", + "description": "Control which teams are shown", + "properties": { + "show_favorite_teams_only": { + "type": "boolean", + "default": true, + "description": "Only show games from favorite teams" + }, + "show_all_live": { + "type": "boolean", + "default": false, + "description": "Show all live games, not just favorites" + }, + "favorite_live_boost": { + "x-advanced": true, + "type": "integer", + "default": 2, + "minimum": 1, + "maximum": 5, + "description": "How many turns your favorite team's live game gets in the rotation for every 1 turn other live games get. Your favorite's game is also always queued first whenever the live rotation refreshes. Set to 1 for even rotation." + } + } + }, + "mode_durations": { + "type": "object", + "title": "Mode Duration Overrides", + "description": "Override how long each mode displays before rotating", + "properties": { + "recent_mode_duration": { + "x-advanced": true, + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for recent games mode (seconds). Null uses default." + }, + "upcoming_mode_duration": { + "x-advanced": true, + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for upcoming games mode (seconds). Null uses default." + }, + "live_mode_duration": { + "x-advanced": true, + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for live games mode (seconds). Null uses default." + } + } + }, + "dynamic_duration": { + "type": "object", + "title": "MiLB Dynamic Duration Settings", + "description": "Configure dynamic duration settings for MiLB games.", + "properties": { + "enabled": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for MiLB games" + }, + "min_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 10, + "maximum": 300, + "default": 30, + "description": "Minimum total duration in seconds for this mode, even if few games are available. Ensures the mode stays visible long enough." + }, + "max_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum total duration in seconds for this mode, even if many games are available." + }, + "modes": { + "type": "object", + "title": "Per-Mode Settings for MiLB", + "description": "Configure dynamic duration for specific MiLB modes", + "properties": { + "live": { + "type": "object", + "properties": { + "enabled": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for MiLB live games" }, - "default": [], - "uniqueItems": true, - "maxItems": 300, - "description": "List of favorite NCAA Baseball team abbreviations (e.g., LSU, FLA). Use 2-4 letter codes." - }, - "exclude_teams": { - "x-advanced": true, - "type": "array", - "items": { - "type": "string" + "min_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for MiLB live mode" }, - "default": [], - "uniqueItems": true, - "maxItems": 300, - "description": "Team abbreviations to always hide from the live rotation and recent/final scores (e.g., to avoid spoilers if you're watching a game delayed). Takes priority over favorite_teams and every other filtering setting." - }, - "display_modes": { - "type": "object", - "title": "Display Modes", - "description": "Control which game types to show", - "properties": { - "show_live": { - "type": "boolean", - "default": true, - "description": "Show live NCAA Baseball games" - }, - "show_recent": { - "type": "boolean", - "default": true, - "description": "Show recently completed NCAA Baseball games" - }, - "show_upcoming": { - "type": "boolean", - "default": true, - "description": "Show upcoming NCAA Baseball games" - }, - "live_display_mode": { - "x-advanced": true, - "type": "string", - "enum": [ - "switch", - "scroll" - ], - "default": "switch", - "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" - }, - "recent_display_mode": { - "x-advanced": true, - "type": "string", - "enum": [ - "switch", - "scroll" - ], - "default": "switch", - "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" - }, - "upcoming_display_mode": { - "x-advanced": true, - "type": "string", - "enum": [ - "switch", - "scroll" - ], - "default": "switch", - "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" - } - } - }, - "scroll_settings": { - "type": "object", - "title": "Scroll Settings", - "description": "Settings for scroll display mode (when display mode is set to 'scroll')", - "properties": { - "scroll_speed": { - "x-advanced": true, - "type": "number", - "default": 50.0, - "minimum": 1.0, - "maximum": 200.0, - "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." - }, - "scroll_delay": { - "x-advanced": true, - "type": "number", - "default": 0.01, - "minimum": 0.001, - "maximum": 0.1, - "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." - }, - "gap_between_games": { - "x-advanced": true, - "type": "integer", - "default": 48, - "minimum": 8, - "maximum": 128, - "description": "Gap in pixels between game cards when scrolling" - }, - "show_league_separators": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "Show league icons between different leagues" - }, - "dynamic_duration": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "Automatically calculate display duration based on content width" - }, - "game_card_width": { - "x-advanced": true, - "type": "integer", - "default": 128, - "minimum": 32, - "maximum": 512, - "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." - } - } - }, - "live_priority": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "Give live games priority over other modes. When enabled, live games will interrupt the normal mode rotation and be displayed immediately when available." - }, - "live_game_duration": { - "x-advanced": true, - "type": "integer", - "default": 30, - "minimum": 10, - "maximum": 120, - "description": "Duration in seconds to display each live game before rotating to the next. When a separate non-favorite duration is set, this applies to games with a favorite team; it applies to ALL live games when no favorite teams are configured." - }, - "non_favorite_live_game_duration": { - "x-advanced": true, - "type": "integer", - "default": 0, - "minimum": 0, - "maximum": 120, - "description": "Duration in seconds for live games that do NOT involve a favorite team. Only applies when favorite teams are set AND non-favorite live games are shown ('show_favorite_teams_only' off, or 'show_all_live' on). 0 (default) = use live_game_duration for every live game (no change)." - }, - "recent_game_duration": { - "x-advanced": true, - "type": "number", - "default": 15, - "minimum": 10, - "maximum": 120, - "description": "Duration in seconds to show each recent game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." - }, - "upcoming_game_duration": { - "x-advanced": true, - "type": "number", - "default": 15, - "minimum": 10, - "maximum": 120, - "description": "Duration in seconds to show each upcoming game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." - }, - "live_update_interval": { - "x-advanced": true, - "type": "integer", - "default": 30, - "minimum": 5, - "maximum": 300, - "description": "How often to update live game data (seconds)" - }, - "update_interval_seconds": { - "x-advanced": true, - "type": "integer", - "default": 3600, - "minimum": 30, - "maximum": 86400, - "description": "How often to fetch new data for this league (seconds)" - }, - "game_limits": { - "type": "object", - "title": "Game Limits", - "description": "Control how many games to show", - "properties": { - "recent_games_to_show": { - "type": "integer", - "default": 1, - "minimum": 1, - "maximum": 20, - "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." - }, - "upcoming_games_to_show": { - "type": "integer", - "default": 1, - "minimum": 1, - "maximum": 20, - "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." - } + "max_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for MiLB live games" } + } }, - "display_options": { - "type": "object", - "title": "Display Options", - "description": "Additional information to show", - "properties": { - "show_records": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Show team records (wins-losses)" - }, - "show_ranking": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Show team rankings (rankings can be important in college baseball)" - }, - "show_odds": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "Show betting odds" - }, - "show_series_summary": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Show series summary information" - }, - "show_pitcher_batter": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Periodically show a dedicated screen with the current pitcher and batter during a live at-bat (requires an extra per-game data fetch)" - }, - "show_last_play": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Show a short code for the most recently completed play (1B, HR, K, BB, etc.) on the pitcher/batter screen" - }, - "show_player_card": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Periodically show a full player card for the current batter (and optionally pitcher): headshot, jersey number, position, bat/throw, and season stats (AVG/HR/RBI for hitters, ERA/W-L/K for pitchers). Requires an extra ESPN athlete lookup; not available for MiLB. Configure look/timing under Customization > Player Card" - }, - "show_traditional_scoreboard": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Periodically show a full-screen traditional ballpark scoreboard: inning-by-inning line score, R/H/E, and an At Bat panel with ball/strike/out indicators" - } - } - }, - "filtering": { - "type": "object", - "title": "Filtering Options", - "description": "Control which teams are shown", - "properties": { - "show_favorite_teams_only": { - "type": "boolean", - "default": true, - "description": "Only show games from favorite teams" - }, - "show_all_live": { - "type": "boolean", - "default": false, - "description": "Show all live games, not just favorites" - }, - "favorite_live_boost": { - "x-advanced": true, - "type": "integer", - "default": 2, - "minimum": 1, - "maximum": 5, - "description": "How many turns your favorite team's live game gets in the rotation for every 1 turn other live games get. Your favorite's game is also always queued first whenever the live rotation refreshes. Set to 1 for even rotation." - } - } - }, - "mode_durations": { - "type": "object", - "title": "Mode Duration Overrides", - "description": "Override how long each mode displays before rotating", - "properties": { - "recent_mode_duration": { - "x-advanced": true, - "type": [ - "number", - "null" - ], - "default": null, - "minimum": 10, - "maximum": 600, - "description": "Override display duration for recent games mode (seconds). Null uses default." - }, - "upcoming_mode_duration": { - "x-advanced": true, - "type": [ - "number", - "null" - ], - "default": null, - "minimum": 10, - "maximum": 600, - "description": "Override display duration for upcoming games mode (seconds). Null uses default." - }, - "live_mode_duration": { - "x-advanced": true, - "type": [ - "number", - "null" - ], - "default": null, - "minimum": 10, - "maximum": 600, - "description": "Override display duration for live games mode (seconds). Null uses default." - } + "recent": { + "type": "object", + "properties": { + "enabled": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for MiLB recent games" + }, + "min_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for MiLB recent mode" + }, + "max_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for MiLB recent games" } + } }, - "dynamic_duration": { - "type": "object", - "title": "NCAA Baseball Dynamic Duration Settings", - "description": "Configure dynamic duration settings for NCAA Baseball games.", - "properties": { - "enabled": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Enable dynamic duration for NCAA Baseball games" - }, - "min_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 10, - "maximum": 300, - "default": 30, - "description": "Minimum total duration in seconds for this mode, even if few games are available. Ensures the mode stays visible long enough." - }, - "max_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 60, - "maximum": 600, - "description": "Maximum total duration in seconds for this mode, even if many games are available." - }, - "modes": { - "type": "object", - "title": "Per-Mode Settings for NCAA Baseball", - "description": "Configure dynamic duration for specific NCAA Baseball modes", - "properties": { - "live": { - "type": "object", - "properties": { - "enabled": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Enable dynamic duration for NCAA Baseball live games" - }, - "min_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 10, - "maximum": 300, - "description": "Minimum duration for NCAA Baseball live mode" - }, - "max_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 60, - "maximum": 600, - "description": "Max duration for NCAA Baseball live games" - } - } - }, - "recent": { - "type": "object", - "properties": { - "enabled": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Enable dynamic duration for NCAA Baseball recent games" - }, - "min_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 10, - "maximum": 300, - "description": "Minimum duration for NCAA Baseball recent mode" - }, - "max_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 60, - "maximum": 600, - "description": "Max duration for NCAA Baseball recent games" - } - } - }, - "upcoming": { - "type": "object", - "properties": { - "enabled": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Enable dynamic duration for NCAA Baseball upcoming games" - }, - "min_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 10, - "maximum": 300, - "description": "Minimum duration for NCAA Baseball upcoming mode" - }, - "max_duration_seconds": { - "x-advanced": true, - "type": "number", - "minimum": 60, - "maximum": 600, - "description": "Max duration for NCAA Baseball upcoming games" - } - } - } - } - } + "upcoming": { + "type": "object", + "properties": { + "enabled": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for MiLB upcoming games" + }, + "min_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for MiLB upcoming mode" + }, + "max_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for MiLB upcoming games" } + } } + } + } + } + } + } + }, + "ncaa_baseball": { + "type": "object", + "title": "NCAA Baseball Settings", + "description": "Configuration for NCAA Baseball games", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable NCAA Baseball games" + }, + "favorite_teams": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "uniqueItems": true, + "maxItems": 300, + "description": "List of favorite NCAA Baseball team abbreviations (e.g., LSU, FLA). Use 2-4 letter codes." + }, + "exclude_teams": { + "x-advanced": true, + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "uniqueItems": true, + "maxItems": 300, + "description": "Team abbreviations to always hide from the live rotation and recent/final scores (e.g., to avoid spoilers if you're watching a game delayed). Takes priority over favorite_teams and every other filtering setting." + }, + "display_modes": { + "type": "object", + "title": "Display Modes", + "description": "Control which game types to show", + "properties": { + "show_live": { + "type": "boolean", + "default": true, + "description": "Show live NCAA Baseball games" + }, + "show_recent": { + "type": "boolean", + "default": true, + "description": "Show recently completed NCAA Baseball games" + }, + "show_upcoming": { + "type": "boolean", + "default": true, + "description": "Show upcoming NCAA Baseball games" + }, + "live_display_mode": { + "x-advanced": true, + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "recent_display_mode": { + "x-advanced": true, + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "upcoming_display_mode": { + "x-advanced": true, + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + } + } + }, + "scroll_settings": { + "type": "object", + "title": "Scroll Settings", + "description": "Settings for scroll display mode (when display mode is set to 'scroll')", + "properties": { + "scroll_speed": { + "x-advanced": true, + "type": "number", + "default": 50.0, + "minimum": 1.0, + "maximum": 200.0, + "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." + }, + "scroll_delay": { + "x-advanced": true, + "type": "number", + "default": 0.01, + "minimum": 0.001, + "maximum": 0.1, + "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." + }, + "gap_between_games": { + "x-advanced": true, + "type": "integer", + "default": 48, + "minimum": 8, + "maximum": 128, + "description": "Gap in pixels between game cards when scrolling" + }, + "show_league_separators": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Show league icons between different leagues" + }, + "dynamic_duration": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "x-advanced": true, + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "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." } + } + }, + "live_priority": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Give live games priority over other modes. When enabled, live games will interrupt the normal mode rotation and be displayed immediately when available." + }, + "live_game_duration": { + "x-advanced": true, + "type": "integer", + "default": 30, + "minimum": 10, + "maximum": 120, + "description": "Duration in seconds to display each live game before rotating to the next. When a separate non-favorite duration is set, this applies to games with a favorite team; it applies to ALL live games when no favorite teams are configured." }, - "customization": { - "type": "object", - "title": "Display Customization", - "description": "Customize fonts, colors, and layout positioning for scoreboard elements", - "properties": { - "score_text": { - "type": "object", - "title": "Score Text", - "description": "Customize the score display", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "default": "press_start", - "description": "Font family for score text" - }, - "font_size": { - "x-advanced": true, - "type": "integer", - "default": 10, - "description": "Font size for score text" - } + "non_favorite_live_game_duration": { + "x-advanced": true, + "type": "integer", + "default": 0, + "minimum": 0, + "maximum": 120, + "description": "Duration in seconds for live games that do NOT involve a favorite team. Only applies when favorite teams are set AND non-favorite live games are shown ('show_favorite_teams_only' off, or 'show_all_live' on). 0 (default) = use live_game_duration for every live game (no change)." + }, + "recent_game_duration": { + "x-advanced": true, + "type": "number", + "default": 15, + "minimum": 10, + "maximum": 120, + "description": "Duration in seconds to show each recent game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." + }, + "upcoming_game_duration": { + "x-advanced": true, + "type": "number", + "default": 15, + "minimum": 10, + "maximum": 120, + "description": "Duration in seconds to show each upcoming game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." + }, + "live_update_interval": { + "x-advanced": true, + "type": "integer", + "default": 30, + "minimum": 5, + "maximum": 300, + "description": "How often to update live game data (seconds)" + }, + "update_interval_seconds": { + "x-advanced": true, + "type": "integer", + "default": 3600, + "minimum": 30, + "maximum": 86400, + "description": "How often to fetch new data for this league (seconds)" + }, + "game_limits": { + "type": "object", + "title": "Game Limits", + "description": "Control how many games to show", + "properties": { + "recent_games_to_show": { + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + }, + "upcoming_games_to_show": { + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + } + } + }, + "display_options": { + "type": "object", + "title": "Display Options", + "description": "Additional information to show", + "properties": { + "show_records": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Show team records (wins-losses)" + }, + "show_ranking": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Show team rankings (rankings can be important in college baseball)" + }, + "show_odds": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Show betting odds" + }, + "show_series_summary": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Show series summary information" + }, + "show_pitcher_batter": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Periodically show a dedicated screen with the current pitcher and batter during a live at-bat (requires an extra per-game data fetch)" + }, + "show_last_play": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Show a short code for the most recently completed play (1B, HR, K, BB, etc.) on the pitcher/batter screen" + }, + "show_player_card": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Periodically show a full player card for the current batter (and optionally pitcher): headshot, jersey number, position, bat/throw, and season stats (AVG/HR/RBI for hitters, ERA/W-L/K for pitchers). Requires an extra ESPN athlete lookup; not available for MiLB. Configure look/timing under Customization > Player Card" + }, + "show_traditional_scoreboard": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Periodically show a full-screen traditional ballpark scoreboard: inning-by-inning line score, R/H/E, and an At Bat panel with ball/strike/out indicators" + } + } + }, + "filtering": { + "type": "object", + "title": "Filtering Options", + "description": "Control which teams are shown", + "properties": { + "show_favorite_teams_only": { + "type": "boolean", + "default": true, + "description": "Only show games from favorite teams" + }, + "show_all_live": { + "type": "boolean", + "default": false, + "description": "Show all live games, not just favorites" + }, + "favorite_live_boost": { + "x-advanced": true, + "type": "integer", + "default": 2, + "minimum": 1, + "maximum": 5, + "description": "How many turns your favorite team's live game gets in the rotation for every 1 turn other live games get. Your favorite's game is also always queued first whenever the live rotation refreshes. Set to 1 for even rotation." + } + } + }, + "mode_durations": { + "type": "object", + "title": "Mode Duration Overrides", + "description": "Override how long each mode displays before rotating", + "properties": { + "recent_mode_duration": { + "x-advanced": true, + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for recent games mode (seconds). Null uses default." + }, + "upcoming_mode_duration": { + "x-advanced": true, + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for upcoming games mode (seconds). Null uses default." + }, + "live_mode_duration": { + "x-advanced": true, + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for live games mode (seconds). Null uses default." + } + } + }, + "dynamic_duration": { + "type": "object", + "title": "NCAA Baseball Dynamic Duration Settings", + "description": "Configure dynamic duration settings for NCAA Baseball games.", + "properties": { + "enabled": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for NCAA Baseball games" + }, + "min_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 10, + "maximum": 300, + "default": 30, + "description": "Minimum total duration in seconds for this mode, even if few games are available. Ensures the mode stays visible long enough." + }, + "max_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum total duration in seconds for this mode, even if many games are available." + }, + "modes": { + "type": "object", + "title": "Per-Mode Settings for NCAA Baseball", + "description": "Configure dynamic duration for specific NCAA Baseball modes", + "properties": { + "live": { + "type": "object", + "properties": { + "enabled": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for NCAA Baseball live games" }, - "additionalProperties": false - }, - "status_text": { - "type": "object", - "title": "Status Text", - "description": "Customize the status/inning display", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "default": "press_start", - "description": "Font family for status text" - }, - "font_size": { - "x-advanced": true, - "type": "integer", - "default": 8, - "description": "Font size for status text" - } + "min_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for NCAA Baseball live mode" }, - "additionalProperties": false + "max_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for NCAA Baseball live games" + } + } }, - "detail_text": { - "type": "object", - "title": "Detail Text", - "description": "Customize detail text (records, etc.)", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "default": "four_by_six", - "description": "Font family for detail text" - }, - "font_size": { - "x-advanced": true, - "type": "integer", - "default": 6, - "description": "Font size for detail text" - } + "recent": { + "type": "object", + "properties": { + "enabled": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for NCAA Baseball recent games" }, - "additionalProperties": false - }, - "layout": { - "type": "object", - "title": "Layout Positioning", - "description": "Adjust X,Y coordinate offsets for elements. Values are relative to default positions. Use negative values to move left/up, positive to move right/down.", - "properties": { - "home_logo": { - "type": "object", - "title": "Home Team Logo", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" - }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } - }, - "additionalProperties": false - }, - "away_logo": { - "type": "object", - "title": "Away Team Logo", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" - }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } - }, - "additionalProperties": false - }, - "score": { - "type": "object", - "title": "Game Score", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" - }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from center (default: 0)" - } - }, - "additionalProperties": false - }, - "status": { - "type": "object", - "title": "Game Status/Inning", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" - }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } - }, - "additionalProperties": false - }, - "record": { - "type": "object", - "title": "Team Records", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" - }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - }, - "away_x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Additional horizontal offset for away team record (default: 0)" - }, - "home_x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Additional horizontal offset for home team record (default: 0)" - } - }, - "additionalProperties": false - }, - "ranking": { - "type": "object", - "title": "Team Rankings", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" - }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } - }, - "additionalProperties": false - }, - "odds": { - "type": "object", - "title": "Betting Odds", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" - }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } - }, - "additionalProperties": false - } + "min_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for NCAA Baseball recent mode" }, - "x-propertyOrder": [ - "home_logo", - "away_logo", - "score", - "status", - "record", - "ranking", - "odds" - ], - "additionalProperties": false + "max_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for NCAA Baseball recent games" + } + } }, - "period_text": { - "type": "object", - "title": "Period/Inning Text", - "description": "Customize the period/inning display font", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "default": "press_start", - "description": "Font family for period/inning text" - }, - "font_size": { - "x-advanced": true, - "type": "integer", - "default": 8, - "description": "Font size for period/inning text" - } + "upcoming": { + "type": "object", + "properties": { + "enabled": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for NCAA Baseball upcoming games" }, - "additionalProperties": false - }, - "team_name": { - "type": "object", - "title": "Team Name Text", - "description": "Customize the team name/abbreviation display", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "default": "press_start", - "description": "Font family for team name text" - }, - "font_size": { - "x-advanced": true, - "type": "integer", - "default": 8, - "description": "Font size for team name text" - } + "min_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for NCAA Baseball upcoming mode" }, - "additionalProperties": false + "max_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for NCAA Baseball upcoming games" + } + } + } + } + } + } + } + } + }, + "customization": { + "type": "object", + "title": "Display Customization", + "description": "Customize fonts, colors, and layout positioning for scoreboard elements", + "properties": { + "score_text": { + "type": "object", + "title": "Score Text", + "description": "Customize the score display", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "default": "press_start", + "description": "Font family for score text" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 10, + "description": "Font size for score text" + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the score text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } + }, + "additionalProperties": false + }, + "status_text": { + "type": "object", + "title": "Status Text", + "description": "Customize the status/inning display", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "default": "press_start", + "description": "Font family for status text" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 8, + "description": "Font size for status text" + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the status text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } + }, + "additionalProperties": false + }, + "detail_text": { + "type": "object", + "title": "Detail Text", + "description": "Customize detail text (records, etc.)", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "default": "four_by_six", + "description": "Font family for detail text" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 6, + "description": "Font size for detail text" + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the detail text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } + }, + "additionalProperties": false + }, + "layout": { + "type": "object", + "title": "Layout Positioning", + "description": "Adjust X,Y coordinate offsets for elements. Values are relative to default positions. Use negative values to move left/up, positive to move right/down.", + "properties": { + "home_logo": { + "type": "object", + "title": "Home Team Logo", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" }, - "rank_text": { - "type": "object", - "title": "Ranking Text", - "description": "Customize the ranking display font", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "default": "press_start", - "description": "Font family for ranking text" - }, - "font_size": { - "x-advanced": true, - "type": "integer", - "default": 10, - "description": "Font size for ranking text" - } - }, - "additionalProperties": false + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "away_logo": { + "type": "object", + "title": "Away Team Logo", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" }, - "bases": { - "type": "object", - "title": "Bases Diamond", - "description": "Customize the base diamond indicators", - "properties": { - "diamond_size": { - "x-advanced": true, - "type": "integer", - "default": 7, - "minimum": 3, - "maximum": 15, - "description": "Size of each base diamond in pixels" - }, - "occupied_color": { - "x-advanced": true, - "type": "array", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, - "default": [255, 255, 255], - "minItems": 3, - "maxItems": 3, - "description": "Color when a base is occupied (RGB)" - }, - "empty_color": { - "x-advanced": true, - "type": "array", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, - "default": [255, 255, 255], - "minItems": 3, - "maxItems": 3, - "description": "Outline color when a base is empty (RGB)" - }, - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "minimum": -50, - "maximum": 50, - "description": "Horizontal offset for the bases cluster (positive = right)" - }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "minimum": -50, - "maximum": 50, - "description": "Vertical offset for the bases cluster (positive = down)" - } + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "score": { + "type": "object", + "title": "Game Score", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" }, - "additionalProperties": false - }, - "outs": { - "type": "object", - "title": "Outs Circles", - "description": "Customize the outs indicator circles", - "properties": { - "circle_diameter": { - "x-advanced": true, - "type": "integer", - "default": 3, - "minimum": 2, - "maximum": 10, - "description": "Diameter of each out circle in pixels" - }, - "counted_color": { - "x-advanced": true, - "type": "array", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, - "default": [255, 255, 255], - "minItems": 3, - "maxItems": 3, - "description": "Fill color for counted outs (RGB)" - }, - "empty_color": { - "x-advanced": true, - "type": "array", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, - "default": [100, 100, 100], - "minItems": 3, - "maxItems": 3, - "description": "Outline color for remaining outs (RGB)" - }, - "spacing": { - "x-advanced": true, - "type": "integer", - "default": 2, - "minimum": 0, - "maximum": 10, - "description": "Vertical spacing between out circles in pixels" - }, - "distance_from_bases": { - "x-advanced": true, - "type": "integer", - "default": 3, - "minimum": 0, - "maximum": 15, - "description": "Horizontal distance from the bases cluster in pixels" - } + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from center (default: 0)" + } + }, + "additionalProperties": false + }, + "status": { + "type": "object", + "title": "Game Status/Inning", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" }, - "additionalProperties": false - }, - "count": { - "type": "object", - "title": "Balls-Strikes Count", - "description": "Customize the balls-strikes count text", - "properties": { - "text_color": { - "x-advanced": true, - "type": "array", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, - "default": [255, 255, 255], - "minItems": 3, - "maxItems": 3, - "description": "Color for the count text (RGB)" - }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 2, - "minimum": 0, - "maximum": 20, - "description": "Pixels below the base cluster" - } + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "record": { + "type": "object", + "title": "Team Records", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" }, - "additionalProperties": false + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" }, - "at_bat_info": { - "type": "object", - "title": "Pitcher / Batter / Last Play Screen", - "description": "A dedicated full-screen view (not an overlay on the scorebug -- there is not enough room there) that periodically rotates into the live display, showing the current pitcher, batter, and a short code for the last completed play. Enable per-league via Display Options > Show Pitcher/Batter and Show Last Play (MLB and NCAA Baseball only).", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "default": "9x15.bdf", - "description": "Font for this screen's text -- a clean, bold bitmap font that auto-fits as large as the display and each line's actual text allows (falling back to a smaller same-family font, e.g. on a narrow display or with a long player name, down to as small as '4x6-font.ttf' -- ('four_by_six') was previously the flat default here, but it renders 'B' and '8' almost identically, making last-play codes like 'BB' misreadable as '88'; the fallback ladder never steps down to it for this reason). A fixed-size .bdf font always renders at its own native pixel size regardless of font_size below; use a scalable .ttf font (e.g. 'press_start') if you want font_size to directly control the size." - }, - "font_size": { - "x-advanced": true, - "type": "integer", - "default": 24, - "minimum": 6, - "maximum": 24, - "description": "Maximum font size cap, for scalable .ttf fonts only (ignored by fixed-size .bdf fonts like the default). This screen auto-fits the largest text that still fits every line, so the default effectively means 'as big as the display and text allow' -- lower it to force a smaller, more consistent size." - }, - "use_team_colors": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "Color the pitcher's name with the fielding team's real ESPN color and the batter's name with the batting team's color, instead of the flat pitcher_color/batter_color below. Falls back to those flat colors when off, or when a team's color isn't available." - }, - "pitcher_color": { - "x-advanced": true, - "type": "array", - "x-widget": "color-picker", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, - "default": [255, 255, 255], - "minItems": 3, - "maxItems": 3, - "description": "Text color [R, G, B] for the pitcher line when use_team_colors is off or the fielding team's color is unavailable" - }, - "batter_color": { - "x-advanced": true, - "type": "array", - "x-widget": "color-picker", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, - "default": [255, 255, 0], - "minItems": 3, - "maxItems": 3, - "description": "Text color [R, G, B] for the batter line when use_team_colors is off or the batting team's color is unavailable" - }, - "last_play_color": { - "x-advanced": true, - "type": "array", - "x-widget": "color-picker", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, - "default": [0, 255, 255], - "minItems": 3, - "maxItems": 3, - "description": "Text color [R, G, B] for the last-play code line" - }, - "dwell_seconds": { - "x-advanced": true, - "type": "number", - "default": 4, - "minimum": 2, - "maximum": 15, - "description": "How many seconds to show this screen each time it rotates in" - }, - "interval_seconds": { - "x-advanced": true, - "type": "number", - "default": 25, - "minimum": 10, - "maximum": 120, - "description": "How often (in seconds) this screen rotates into the normal live view" - }, - "favorites_only": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Only rotate this screen in for games involving one of this league's favorite_teams. Useful if show_all_live is on (so every team's live game rotates through) but you only want the pitcher/batter/last-play treatment for your own team -- other teams still get the normal compact scorebug. Has no effect if favorite_teams is empty." - } + "away_x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Additional horizontal offset for away team record (default: 0)" }, - "additionalProperties": false - }, - "player_card": { - "type": "object", - "title": "Player Card Screen", - "description": "A dedicated full-screen player card that periodically rotates into the live display for the current batter (and optionally pitcher): headshot image, jersey number, position, bat/throw hand, and season stats (AVG/HR/RBI for hitters, ERA/W-L/K for pitchers). Enable per-league via Display Options > Show Player Card (MLB and NCAA Baseball only -- MiLB has no ESPN player data). On tiny panels (e.g. 64x32) the headshot is hidden and the card shows compact text.", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "default": "9x15.bdf", - "description": "Font for the card's text -- a clean, bold bitmap font that auto-fits the largest size the panel and text allow. A fixed-size .bdf font renders at its native pixel size regardless of font_size; use a scalable .ttf font (e.g. 'press_start') if you want font_size to control the size." - }, - "font_size": { - "x-advanced": true, - "type": "integer", - "default": 24, - "minimum": 6, - "maximum": 24, - "description": "Maximum font size cap, for scalable .ttf fonts only (ignored by fixed-size .bdf fonts like the default). The card auto-fits within the space beside the headshot, so the default effectively means 'as big as fits' -- lower it to force a smaller size." - }, - "show_batter": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "Show a card for the current batter" - }, - "show_pitcher": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Also show a card for the current pitcher (the batter is preferred when both are available)" - }, - "use_team_colors": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "Color the player's name with their real ESPN team color instead of the flat text_color" - }, - "use_team_colors_border": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "Draw the headshot's frame in the player's team color instead of the flat border_color below" - }, - "border_color": { - "x-advanced": true, - "type": "array", - "x-widget": "color-picker", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, - "default": [255, 200, 0], - "minItems": 3, - "maxItems": 3, - "description": "Headshot frame color [R, G, B] when use_team_colors_border is off or the team color is unavailable" - }, - "text_color": { - "x-advanced": true, - "type": "array", - "x-widget": "color-picker", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, - "default": [255, 255, 255], - "minItems": 3, - "maxItems": 3, - "description": "Text color [R, G, B] for the name (when team colors are off) and the jersey/position/bat-throw line" - }, - "stat_color": { - "x-advanced": true, - "type": "array", - "x-widget": "color-picker", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, - "default": [0, 220, 255], - "minItems": 3, - "maxItems": 3, - "description": "Text color [R, G, B] for the season-stats line" - }, - "dwell_seconds": { - "x-advanced": true, - "type": "number", - "default": 6, - "minimum": 2, - "maximum": 20, - "description": "How many seconds to show the card each time it rotates in" - }, - "interval_seconds": { - "x-advanced": true, - "type": "number", - "default": 40, - "minimum": 10, - "maximum": 180, - "description": "How often (in seconds) the card rotates into the normal live view" - }, - "favorites_only": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Only rotate the card in for games involving one of this league's favorite_teams. Has no effect if favorite_teams is empty." - } + "home_x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Additional horizontal offset for home team record (default: 0)" + } + }, + "additionalProperties": false + }, + "ranking": { + "type": "object", + "title": "Team Rankings", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" }, - "additionalProperties": false - }, - "traditional_scoreboard": { - "type": "object", - "title": "Traditional Scoreboard Screen", - "description": "A dedicated full-screen view (like an outfield ballpark scoreboard) that periodically rotates into the live display: an inning-by-inning line score with R/H/E, and an At Bat panel with ball/strike/out indicators. Enable per-league via Display Options > Show Traditional Scoreboard (MLB and NCAA Baseball only).", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "default": "9x15.bdf", - "description": "Font for this screen's text. '9x15.bdf' is a clean, bold, highly legible bitmap font. Fixed-size bitmap (.bdf) fonts always render at their own native pixel size regardless of font_size below; use a scalable .ttf font (e.g. 'press_start' for PressStart2P, a chunkier 8-bit retro look) if you want font_size to control the size" - }, - "font_size": { - "x-advanced": true, - "type": "integer", - "default": 24, - "minimum": 6, - "maximum": 24, - "description": "Maximum font size cap, for scalable .ttf fonts only (ignored by fixed-size .bdf fonts like the default). This screen auto-fits the largest text that still leaves room for the At Bat panel (when applicable), so the default (24) effectively means 'as big as the display allows' -- lower this to force a smaller, more consistent size instead" - }, - "use_team_colors": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "Color each team's abbreviation using their real team colors (from ESPN) instead of a single flat text color" - }, - "show_team_color_backgrounds": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "Tint each team's row with a subtle (~12% brightness) wash of their real ESPN team color, plus a solid team-color accent strip on the left edge -- a colorful ballpark look. Requires use_team_colors; a team with no ESPN color gets no tint. Text stays legible (it is drawn with a black outline over the wash)." - }, - "show_logos": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "Show a small team logo beside each abbreviation, but only when there's leftover width to spare -- never at the cost of a displayed inning or the At Bat side panel" - }, - "text_color": { - "x-advanced": true, - "type": "array", - "x-widget": "color-picker", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, - "default": [255, 255, 255], - "minItems": 3, - "maxItems": 3, - "description": "Text color [R, G, B] for score digits, and for team abbreviations when team colors are off or unavailable" - }, - "header_color": { - "x-advanced": true, - "type": "array", - "x-widget": "color-picker", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, - "default": [180, 180, 180], - "minItems": 3, - "maxItems": 3, - "description": "Text color [R, G, B] for the inning-number and R/H/E header row" - }, - "highlight_color": { - "x-advanced": true, - "type": "array", - "x-widget": "color-picker", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, - "default": [255, 140, 0], - "minItems": 3, - "maxItems": 3, - "description": "Accent color [R, G, B] for the current-inning highlight, the At Bat label, and lit ball/strike/out indicators" - }, - "show_dividers": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "Draw thin 1px grid lines between innings, rows, and the R/H/E columns for readability" - }, - "game_scope": { - "x-advanced": true, - "type": "string", - "enum": ["live", "recent", "both"], - "default": "both", - "description": "Which games this screen rotates in for: 'live' only during live action, 'recent' only for final/completed games (handy for seeing the winner and full line score at a glance), or 'both'" - }, - "favorites_only": { - "x-advanced": true, - "type": "boolean", - "default": false, - "description": "Only rotate this screen in for games involving one of this league's favorite_teams. Useful if show_all_live is on (so every team's live game rotates through) but you only want the full-screen treatment for your own team -- other teams still get the normal compact scorebug. Has no effect if favorite_teams is empty." - }, - "divider_color": { - "x-advanced": true, - "type": "array", - "x-widget": "color-picker", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, - "default": [90, 90, 90], - "minItems": 3, - "maxItems": 3, - "description": "Color [R, G, B] for the grid divider lines" - }, - "highlight_winner": { - "x-advanced": true, - "type": "boolean", - "default": true, - "description": "On a final game, color the winning team's run total in winner_color so the winner is obvious at a glance instead of having to compare both R values yourself" - }, - "winner_color": { - "x-advanced": true, - "type": "array", - "x-widget": "color-picker", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, - "default": [0, 200, 0], - "minItems": 3, - "maxItems": 3, - "description": "Color [R, G, B] for the winning team's run total on a final game" - }, - "dwell_seconds": { - "x-advanced": true, - "type": "number", - "default": 6, - "minimum": 2, - "maximum": 20, - "description": "How many seconds to show this screen each time it rotates in" - }, - "interval_seconds": { - "x-advanced": true, - "type": "number", - "default": 30, - "minimum": 10, - "maximum": 180, - "description": "How often (in seconds) this screen rotates into the normal live view" - } + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "odds": { + "type": "object", + "title": "Betting Odds", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" }, - "additionalProperties": false - }, - "favorite_result_colors": { - "type": "object", - "title": "Favorite Team Result Colors", - "description": "Color the final score of a recent game by how your favorite team did. Most useful in scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not tell you who won.", - "properties": { - "enabled": { - "type": "boolean", - "title": "Color Scores by Result", - "description": "Show the final score in green when your favorite team won and red when it lost. Games without exactly one favorite team - neither side, or both - keep the normal score color.", - "default": false - }, - "win_color": { - "type": "array", - "title": "Win Color", - "description": "Score color [R, G, B] when your favorite team won", - "x-widget": "color-picker", - "items": { - "type": "integer", - "minimum": 0, - "maximum": 255 - }, - "minItems": 3, - "maxItems": 3, - "default": [0, 255, 0], - "x-advanced": true - }, - "loss_color": { - "type": "array", - "title": "Loss Color", - "description": "Score color [R, G, B] when your favorite team lost", - "x-widget": "color-picker", - "items": { - "type": "integer", - "minimum": 0, - "maximum": 255 - }, - "minItems": 3, - "maxItems": 3, - "default": [255, 0, 0], - "x-advanced": true - }, - "tie_color": { - "type": "array", - "title": "Tie Color", - "description": "Score color [R, G, B] when the game ended level", - "x-widget": "color-picker", - "items": { - "type": "integer", - "minimum": 0, - "maximum": 255 - }, - "minItems": 3, - "maxItems": 3, - "default": [255, 200, 0], - "x-advanced": true - } - }, - "x-propertyOrder": [ - "enabled", - "win_color", - "loss_color", - "tie_color" - ], - "additionalProperties": false + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" } + }, + "additionalProperties": false + } + }, + "x-propertyOrder": [ + "home_logo", + "away_logo", + "score", + "status", + "record", + "ranking", + "odds" + ], + "additionalProperties": false + }, + "period_text": { + "type": "object", + "title": "Period/Inning Text", + "description": "Customize the period/inning display font", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "default": "press_start", + "description": "Font family for period/inning text" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 8, + "description": "Font size for period/inning text" + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the period text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } + }, + "additionalProperties": false + }, + "team_name": { + "type": "object", + "title": "Team Name Text", + "description": "Customize the team name/abbreviation display", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "default": "press_start", + "description": "Font family for team name text" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 8, + "description": "Font size for team name text" + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the team name on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } + }, + "additionalProperties": false + }, + "rank_text": { + "type": "object", + "title": "Ranking Text", + "description": "Customize the ranking display font", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "default": "press_start", + "description": "Font family for ranking text" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 10, + "description": "Font size for ranking text" + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the rank text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } + }, + "additionalProperties": false + }, + "bases": { + "type": "object", + "title": "Bases Diamond", + "description": "Customize the base diamond indicators", + "properties": { + "diamond_size": { + "x-advanced": true, + "type": "integer", + "default": 7, + "minimum": 3, + "maximum": 15, + "description": "Size of each base diamond in pixels" + }, + "occupied_color": { + "x-advanced": true, + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "default": [ + 255, + 255, + 255 + ], + "minItems": 3, + "maxItems": 3, + "description": "Color when a base is occupied (RGB)" + }, + "empty_color": { + "x-advanced": true, + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "default": [ + 255, + 255, + 255 + ], + "minItems": 3, + "maxItems": 3, + "description": "Outline color when a base is empty (RGB)" + }, + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "minimum": -50, + "maximum": 50, + "description": "Horizontal offset for the bases cluster (positive = right)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "minimum": -50, + "maximum": 50, + "description": "Vertical offset for the bases cluster (positive = down)" + } + }, + "additionalProperties": false + }, + "outs": { + "type": "object", + "title": "Outs Circles", + "description": "Customize the outs indicator circles", + "properties": { + "circle_diameter": { + "x-advanced": true, + "type": "integer", + "default": 3, + "minimum": 2, + "maximum": 10, + "description": "Diameter of each out circle in pixels" + }, + "counted_color": { + "x-advanced": true, + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "default": [ + 255, + 255, + 255 + ], + "minItems": 3, + "maxItems": 3, + "description": "Fill color for counted outs (RGB)" + }, + "empty_color": { + "x-advanced": true, + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "default": [ + 100, + 100, + 100 + ], + "minItems": 3, + "maxItems": 3, + "description": "Outline color for remaining outs (RGB)" }, - "x-propertyOrder": [ - "score_text", - "period_text", - "team_name", - "status_text", - "detail_text", - "rank_text", - "layout", - "bases", - "outs", - "count", - "at_bat_info", - "player_card", - "traditional_scoreboard", - "favorite_result_colors" - ], - "additionalProperties": false + "spacing": { + "x-advanced": true, + "type": "integer", + "default": 2, + "minimum": 0, + "maximum": 10, + "description": "Vertical spacing between out circles in pixels" + }, + "distance_from_bases": { + "x-advanced": true, + "type": "integer", + "default": 3, + "minimum": 0, + "maximum": 15, + "description": "Horizontal distance from the bases cluster in pixels" + } + }, + "additionalProperties": false + }, + "count": { + "type": "object", + "title": "Balls-Strikes Count", + "description": "Customize the balls-strikes count text", + "properties": { + "text_color": { + "x-advanced": true, + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "default": [ + 255, + 255, + 255 + ], + "minItems": 3, + "maxItems": 3, + "description": "Color for the count text (RGB)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 2, + "minimum": 0, + "maximum": 20, + "description": "Pixels below the base cluster" + } + }, + "additionalProperties": false + }, + "at_bat_info": { + "type": "object", + "title": "Pitcher / Batter / Last Play Screen", + "description": "A dedicated full-screen view (not an overlay on the scorebug -- there is not enough room there) that periodically rotates into the live display, showing the current pitcher, batter, and a short code for the last completed play. Enable per-league via Display Options > Show Pitcher/Batter and Show Last Play (MLB and NCAA Baseball only).", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "default": "9x15.bdf", + "description": "Font for this screen's text -- a clean, bold bitmap font that auto-fits as large as the display and each line's actual text allows (falling back to a smaller same-family font, e.g. on a narrow display or with a long player name, down to as small as '4x6-font.ttf' -- ('four_by_six') was previously the flat default here, but it renders 'B' and '8' almost identically, making last-play codes like 'BB' misreadable as '88'; the fallback ladder never steps down to it for this reason). A fixed-size .bdf font always renders at its own native pixel size regardless of font_size below; use a scalable .ttf font (e.g. 'press_start') if you want font_size to directly control the size." + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 24, + "minimum": 6, + "maximum": 24, + "description": "Maximum font size cap, for scalable .ttf fonts only (ignored by fixed-size .bdf fonts like the default). This screen auto-fits the largest text that still fits every line, so the default effectively means 'as big as the display and text allow' -- lower it to force a smaller, more consistent size." + }, + "use_team_colors": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Color the pitcher's name with the fielding team's real ESPN color and the batter's name with the batting team's color, instead of the flat pitcher_color/batter_color below. Falls back to those flat colors when off, or when a team's color isn't available." + }, + "pitcher_color": { + "x-advanced": true, + "type": "array", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "default": [ + 255, + 255, + 255 + ], + "minItems": 3, + "maxItems": 3, + "description": "Text color [R, G, B] for the pitcher line when use_team_colors is off or the fielding team's color is unavailable" + }, + "batter_color": { + "x-advanced": true, + "type": "array", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "default": [ + 255, + 255, + 0 + ], + "minItems": 3, + "maxItems": 3, + "description": "Text color [R, G, B] for the batter line when use_team_colors is off or the batting team's color is unavailable" + }, + "last_play_color": { + "x-advanced": true, + "type": "array", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "default": [ + 0, + 255, + 255 + ], + "minItems": 3, + "maxItems": 3, + "description": "Text color [R, G, B] for the last-play code line" + }, + "dwell_seconds": { + "x-advanced": true, + "type": "number", + "default": 4, + "minimum": 2, + "maximum": 15, + "description": "How many seconds to show this screen each time it rotates in" + }, + "interval_seconds": { + "x-advanced": true, + "type": "number", + "default": 25, + "minimum": 10, + "maximum": 120, + "description": "How often (in seconds) this screen rotates into the normal live view" + }, + "favorites_only": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Only rotate this screen in for games involving one of this league's favorite_teams. Useful if show_all_live is on (so every team's live game rotates through) but you only want the pitcher/batter/last-play treatment for your own team -- other teams still get the normal compact scorebug. Has no effect if favorite_teams is empty." + } + }, + "additionalProperties": false + }, + "player_card": { + "type": "object", + "title": "Player Card Screen", + "description": "A dedicated full-screen player card that periodically rotates into the live display for the current batter (and optionally pitcher): headshot image, jersey number, position, bat/throw hand, and season stats (AVG/HR/RBI for hitters, ERA/W-L/K for pitchers). Enable per-league via Display Options > Show Player Card (MLB and NCAA Baseball only -- MiLB has no ESPN player data). On tiny panels (e.g. 64x32) the headshot is hidden and the card shows compact text.", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "default": "9x15.bdf", + "description": "Font for the card's text -- a clean, bold bitmap font that auto-fits the largest size the panel and text allow. A fixed-size .bdf font renders at its native pixel size regardless of font_size; use a scalable .ttf font (e.g. 'press_start') if you want font_size to control the size." + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 24, + "minimum": 6, + "maximum": 24, + "description": "Maximum font size cap, for scalable .ttf fonts only (ignored by fixed-size .bdf fonts like the default). The card auto-fits within the space beside the headshot, so the default effectively means 'as big as fits' -- lower it to force a smaller size." + }, + "show_batter": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Show a card for the current batter" + }, + "show_pitcher": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Also show a card for the current pitcher (the batter is preferred when both are available)" + }, + "use_team_colors": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Color the player's name with their real ESPN team color instead of the flat text_color" + }, + "use_team_colors_border": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Draw the headshot's frame in the player's team color instead of the flat border_color below" + }, + "border_color": { + "x-advanced": true, + "type": "array", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "default": [ + 255, + 200, + 0 + ], + "minItems": 3, + "maxItems": 3, + "description": "Headshot frame color [R, G, B] when use_team_colors_border is off or the team color is unavailable" + }, + "text_color": { + "x-advanced": true, + "type": "array", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "default": [ + 255, + 255, + 255 + ], + "minItems": 3, + "maxItems": 3, + "description": "Text color [R, G, B] for the name (when team colors are off) and the jersey/position/bat-throw line" + }, + "stat_color": { + "x-advanced": true, + "type": "array", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "default": [ + 0, + 220, + 255 + ], + "minItems": 3, + "maxItems": 3, + "description": "Text color [R, G, B] for the season-stats line" + }, + "dwell_seconds": { + "x-advanced": true, + "type": "number", + "default": 6, + "minimum": 2, + "maximum": 20, + "description": "How many seconds to show the card each time it rotates in" + }, + "interval_seconds": { + "x-advanced": true, + "type": "number", + "default": 40, + "minimum": 10, + "maximum": 180, + "description": "How often (in seconds) the card rotates into the normal live view" + }, + "favorites_only": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Only rotate the card in for games involving one of this league's favorite_teams. Has no effect if favorite_teams is empty." + } + }, + "additionalProperties": false + }, + "traditional_scoreboard": { + "type": "object", + "title": "Traditional Scoreboard Screen", + "description": "A dedicated full-screen view (like an outfield ballpark scoreboard) that periodically rotates into the live display: an inning-by-inning line score with R/H/E, and an At Bat panel with ball/strike/out indicators. Enable per-league via Display Options > Show Traditional Scoreboard (MLB and NCAA Baseball only).", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "default": "9x15.bdf", + "description": "Font for this screen's text. '9x15.bdf' is a clean, bold, highly legible bitmap font. Fixed-size bitmap (.bdf) fonts always render at their own native pixel size regardless of font_size below; use a scalable .ttf font (e.g. 'press_start' for PressStart2P, a chunkier 8-bit retro look) if you want font_size to control the size" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "default": 24, + "minimum": 6, + "maximum": 24, + "description": "Maximum font size cap, for scalable .ttf fonts only (ignored by fixed-size .bdf fonts like the default). This screen auto-fits the largest text that still leaves room for the At Bat panel (when applicable), so the default (24) effectively means 'as big as the display allows' -- lower this to force a smaller, more consistent size instead" + }, + "use_team_colors": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Color each team's abbreviation using their real team colors (from ESPN) instead of a single flat text color" + }, + "show_team_color_backgrounds": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Tint each team's row with a subtle (~12% brightness) wash of their real ESPN team color, plus a solid team-color accent strip on the left edge -- a colorful ballpark look. Requires use_team_colors; a team with no ESPN color gets no tint. Text stays legible (it is drawn with a black outline over the wash)." + }, + "show_logos": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Show a small team logo beside each abbreviation, but only when there's leftover width to spare -- never at the cost of a displayed inning or the At Bat side panel" + }, + "text_color": { + "x-advanced": true, + "type": "array", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "default": [ + 255, + 255, + 255 + ], + "minItems": 3, + "maxItems": 3, + "description": "Text color [R, G, B] for score digits, and for team abbreviations when team colors are off or unavailable" + }, + "header_color": { + "x-advanced": true, + "type": "array", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "default": [ + 180, + 180, + 180 + ], + "minItems": 3, + "maxItems": 3, + "description": "Text color [R, G, B] for the inning-number and R/H/E header row" + }, + "highlight_color": { + "x-advanced": true, + "type": "array", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "default": [ + 255, + 140, + 0 + ], + "minItems": 3, + "maxItems": 3, + "description": "Accent color [R, G, B] for the current-inning highlight, the At Bat label, and lit ball/strike/out indicators" + }, + "show_dividers": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Draw thin 1px grid lines between innings, rows, and the R/H/E columns for readability" + }, + "game_scope": { + "x-advanced": true, + "type": "string", + "enum": [ + "live", + "recent", + "both" + ], + "default": "both", + "description": "Which games this screen rotates in for: 'live' only during live action, 'recent' only for final/completed games (handy for seeing the winner and full line score at a glance), or 'both'" + }, + "favorites_only": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Only rotate this screen in for games involving one of this league's favorite_teams. Useful if show_all_live is on (so every team's live game rotates through) but you only want the full-screen treatment for your own team -- other teams still get the normal compact scorebug. Has no effect if favorite_teams is empty." + }, + "divider_color": { + "x-advanced": true, + "type": "array", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "default": [ + 90, + 90, + 90 + ], + "minItems": 3, + "maxItems": 3, + "description": "Color [R, G, B] for the grid divider lines" + }, + "highlight_winner": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "On a final game, color the winning team's run total in winner_color so the winner is obvious at a glance instead of having to compare both R values yourself" + }, + "winner_color": { + "x-advanced": true, + "type": "array", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "default": [ + 0, + 200, + 0 + ], + "minItems": 3, + "maxItems": 3, + "description": "Color [R, G, B] for the winning team's run total on a final game" + }, + "dwell_seconds": { + "x-advanced": true, + "type": "number", + "default": 6, + "minimum": 2, + "maximum": 20, + "description": "How many seconds to show this screen each time it rotates in" + }, + "interval_seconds": { + "x-advanced": true, + "type": "number", + "default": 30, + "minimum": 10, + "maximum": 180, + "description": "How often (in seconds) this screen rotates into the normal live view" + } + }, + "additionalProperties": false + }, + "favorite_result_colors": { + "type": "object", + "title": "Favorite Team Result Colors", + "description": "Color the final score of a recent game by how your favorite team did. Most useful in scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not tell you who won.", + "properties": { + "enabled": { + "type": "boolean", + "title": "Color Scores by Result", + "description": "Show the final score in green when your favorite team won and red when it lost. Games without exactly one favorite team - neither side, or both - keep the normal score color.", + "default": false + }, + "win_color": { + "type": "array", + "title": "Win Color", + "description": "Score color [R, G, B] when your favorite team won", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 0, + 255, + 0 + ], + "x-advanced": true + }, + "loss_color": { + "type": "array", + "title": "Loss Color", + "description": "Score color [R, G, B] when your favorite team lost", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 0, + 0 + ], + "x-advanced": true + }, + "tie_color": { + "type": "array", + "title": "Tie Color", + "description": "Score color [R, G, B] when the game ended level", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 200, + 0 + ], + "x-advanced": true + } + }, + "x-propertyOrder": [ + "enabled", + "win_color", + "loss_color", + "tie_color" + ], + "additionalProperties": false } - }, - "additionalProperties": false, - "required": [ - "enabled" - ] + }, + "x-propertyOrder": [ + "score_text", + "period_text", + "team_name", + "status_text", + "detail_text", + "rank_text", + "layout", + "bases", + "outs", + "count", + "at_bat_info", + "player_card", + "traditional_scoreboard", + "favorite_result_colors" + ], + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "enabled" + ] } diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index aed96370..39f2230e 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -402,9 +402,11 @@ def _render_live_game(self, game: Dict) -> Image.Image: # Logos logo_slot = self._logo_slot_width() - away_x = (logo_slot - away_logo.width) // 2 + away_x = ((logo_slot - away_logo.width) // 2 + + self._layout_offset('away_logo', 'x_offset')) main_img.paste(away_logo, (away_x, center_y - away_logo.height // 2), away_logo) - home_x = (self.display_width - logo_slot) + (logo_slot - home_logo.width) // 2 + home_x = ((self.display_width - logo_slot) + (logo_slot - home_logo.width) // 2 + + self._layout_offset('home_logo', 'x_offset')) main_img.paste(home_logo, (home_x, center_y - home_logo.height // 2), home_logo) # Inning indicator (top center) @@ -514,7 +516,8 @@ def _render_live_game(self, game: Dict) -> Image.Image: score_font = self.fonts['score'] score_text = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" score_width = draw.textlength(score_text, font=score_font) - score_x = (self.display_width - score_width) // 2 + score_x = ((self.display_width - score_width) // 2 + + self._layout_offset('score', 'x_offset')) try: font_height = score_font.getbbox("A")[3] - score_font.getbbox("A")[1] except AttributeError: @@ -555,9 +558,11 @@ def _render_recent_game(self, game: Dict) -> Image.Image: # Logos (tighter fit for recent) logo_slot = self._logo_slot_width() - away_x = (logo_slot - away_logo.width) // 2 + away_x = ((logo_slot - away_logo.width) // 2 + + self._layout_offset('away_logo', 'x_offset')) main_img.paste(away_logo, (away_x, center_y - away_logo.height // 2), away_logo) - home_x = (self.display_width - logo_slot) + (logo_slot - home_logo.width) // 2 + home_x = ((self.display_width - logo_slot) + (logo_slot - home_logo.width) // 2 + + self._layout_offset('home_logo', 'x_offset')) main_img.paste(home_logo, (home_x, center_y - home_logo.height // 2), home_logo) # "Final" (top center) @@ -570,7 +575,8 @@ def _render_recent_game(self, game: Dict) -> Image.Image: # the final score gold. score_text = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" score_width = draw.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 + score_x = ((self.display_width - score_width) // 2 + + self._layout_offset('score', 'x_offset')) score_y = self.display_height - 14 self._draw_text_with_outline(draw, score_text, (score_x, score_y), self.fonts['score'], @@ -591,22 +597,23 @@ def _render_recent_game(self, game: Dict) -> Image.Image: return self._render_error_card("Display error") # ------------------------------------------------------------------ - # Scroll/Vegas card options -- config["scroll_card"]. + # Scroll/Vegas card options -- config["scroll_card"], plus the shared + # customization.layout offsets and per-element colours. # # These only affect the cards this renderer builds, which are used by # scroll_display.py and scroll_display_legacy.py alone. The full-screen # scorebug is drawn elsewhere and is deliberately left untouched. # ------------------------------------------------------------------ - # Middle strip kept clear of logos so the score / "VS" is never drawn on - # top of them. 0.28 of a 128px card clears "1-2" (30px) with room spare. CENTER_GAP_RATIO: ClassVar[float] = 0.28 - # 22 so "VS" (20px) still clears the logos on the narrowest 64px card. CENTER_GAP_MIN_PX: ClassVar[int] = 22 CENTER_GAP_MAX_PX: ClassVar[int] = 40 _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ) + _WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = ( + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", + ) def _scroll_card_option(self, key: str, default: Any = None) -> Any: """Read one key from the scroll_card config block.""" @@ -615,16 +622,58 @@ def _scroll_card_option(self, key: str, default: Any = None) -> Any: return block.get(key) return default + def _layout_offset(self, element: str, axis: str, default: int = 0) -> int: + """X/Y nudge for one element, from customization.layout. + + Same block the full-screen scorebug reads (sports.py + _get_layout_offset), so a nudge configured in the web UI now moves + the element on the scroll/Vegas card too -- previously the schema + advertised these offsets but this renderer ignored them. + """ + try: + layout = (self.config or {}).get("customization", {}).get("layout", {}) + value = (layout.get(element) or {}).get(axis, default) + if isinstance(value, bool): + return default + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + return int(float(value)) + except (TypeError, ValueError): + pass + return default + + def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): + """Per-element text colour from customization..text_color.""" + try: + cfg = (self.config or {}).get("customization", {}).get(element, {}) + value = cfg.get("text_color") + if isinstance(value, (list, tuple)) and len(value) == 3: + return tuple(max(0, min(255, int(c))) for c in value) + if isinstance(value, str) and value.startswith("#") and len(value) == 7: + return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) + except (TypeError, ValueError): + pass + return default + def _center_gap_width(self) -> int: """Width of the middle strip kept clear of logos. - ``scroll_card.center_gap`` overrides it; 0 restores edge-to-edge logos. + ``scroll_card.center_gap`` pins it outright; otherwise it scales with + the card width between the configurable min and max. 0 restores + edge-to-edge logos. """ configured = self._scroll_card_option("center_gap") if isinstance(configured, (int, float)) and configured >= 0: return int(configured) - scaled = round(self.display_width * self.CENTER_GAP_RATIO) - return int(max(self.CENTER_GAP_MIN_PX, min(self.CENTER_GAP_MAX_PX, scaled))) + ratio = self._scroll_card_option("center_gap_ratio", self.CENTER_GAP_RATIO) + low = self._scroll_card_option("center_gap_min", self.CENTER_GAP_MIN_PX) + high = self._scroll_card_option("center_gap_max", self.CENTER_GAP_MAX_PX) + try: + scaled = round(self.display_width * float(ratio)) + return int(max(int(low), min(int(high), scaled))) + except (TypeError, ValueError): + return self.CENTER_GAP_MIN_PX def _logo_slot_width(self) -> int: """Per-side logo slot, leaving the center gap clear. @@ -637,50 +686,124 @@ def _logo_slot_width(self) -> int: return max(8, min(self.display_height, available)) def _upcoming_center_mode(self) -> str: - """What sits in the middle of an upcoming card: 'vs' or 'date_time'.""" + """Middle of an upcoming card: 'vs', 'date_time' or 'none'.""" mode = str(self._scroll_card_option("upcoming_center", "vs") or "vs").lower() - return mode if mode in ("vs", "date_time") else "vs" + return mode if mode in ("vs", "date_time", "none") else "vs" - def _format_game_date(self, date_text: str) -> str: - """Render a date as "Sep 19" (default) or "9/19" (``date_format``).""" + def _vs_text(self) -> str: + """Separator drawn between the teams -- "VS", "@", "at", anything.""" + return str(self._scroll_card_option("vs_text", "VS")) + + def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: + """Format an upcoming card's date per scroll_card.date_format.""" raw = str(date_text or "").strip() - if not raw or self._scroll_card_option("date_format", "abbrev") == "numeric": + if not raw: + return "" + fmt = str(self._scroll_card_option("date_format", "abbrev") or "abbrev") + if fmt == "numeric": return raw parts = raw.replace("-", "/").split("/") - if len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): - month = int(parts[0]) - if 1 <= month <= 12: - return f"{self._MONTH_ABBR[month - 1]} {int(parts[1])}" - return raw + if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): + return raw + month, day = int(parts[0]), int(parts[1]) + if not 1 <= month <= 12: + return raw + name = self._MONTH_ABBR[month - 1] + if fmt == "numeric_day_first": + return f"{day}/{month}" + if fmt == "day_first": + return f"{day} {name}" + if fmt == "weekday": + weekday = self._weekday_for(game) + return f"{weekday} {name} {day}" if weekday else f"{name} {day}" + return f"{name} {day}" + + def _weekday_for(self, game: Optional[Dict]) -> str: + """Weekday abbreviation from the game's start time, or ''.""" + if not game: + return "" + raw = game.get("start_time_utc") or game.get("start_time") + if not raw: + return "" + try: + start = raw if isinstance(raw, datetime) else datetime.fromisoformat( + str(raw).replace("Z", "+00:00")) + return self._WEEKDAY_ABBR[start.astimezone(self._card_tzinfo()).weekday()] + except (ValueError, TypeError): + return "" + + def _card_tzinfo(self): + """Timezone for weekday/24h conversions; falls back to UTC.""" + try: + configured = (self.config or {}).get("timezone") + if configured: + return ZoneInfo(configured) + except Exception: + pass + return timezone.utc + + def _format_game_time(self, time_text: str) -> str: + """Return the time as-is (12h) or converted to 24h.""" + raw = str(time_text or "").strip() + if not raw or str(self._scroll_card_option("time_format", "12h")) != "24h": + return raw + cleaned = raw.upper().replace(" ", "") + meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" + if not meridiem: + return raw + try: + hh, _, mm = cleaned[:-2].partition(":") + hour, minute = int(hh), int(mm or 0) + except ValueError: + return raw + if not (0 <= hour <= 12 and 0 <= minute <= 59): + return raw + hour = hour % 12 + (12 if meridiem == "PM" else 0) + return f"{hour:02d}:{minute:02d}" def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: """Draw the middle of an upcoming card. Never a score: an upcoming game has not started, so the extractor's - 0-0 is noise. Either "VS" (default) or the date and time stacked. + 0-0 is noise. Either the VS text (default), the date and time stacked, + or nothing at all. """ - if self._upcoming_center_mode() == "vs": - vs_text = "VS" + mode = self._upcoming_center_mode() + if mode == "none": + return + + if mode == "vs": + vs_text = self._vs_text() + if not vs_text: + return vs_width = draw.textlength(vs_text, font=self.fonts['score']) - vs_x = (self.display_width - vs_width) // 2 - vs_y = (self.display_height // 2) - 3 + vs_x = (self.display_width - vs_width) // 2 + self._layout_offset('score', 'x_offset') + vs_y = (self.display_height // 2) - 3 + self._layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts['score'] + draw, vs_text, (vs_x, vs_y), self.fonts['score'], + fill=self._element_color('score_text') ) return date_text, time_text = self._upcoming_date_and_time(game) - date_text = self._format_game_date(date_text) - font = self.fonts.get('detail') or self.fonts['time'] - lines = [t for t in (date_text, time_text) if t] + lines = [] + if self._scroll_card_option("show_date", True): + lines.append(self._format_game_date(date_text, game)) + if self._scroll_card_option("show_time", True): + lines.append(self._format_game_time(time_text)) + lines = [t for t in lines if t] if not lines: return + font = self.fonts.get('detail') or self.fonts['time'] line_h = 7 top = (self.display_height // 2) - (len(lines) * line_h) // 2 + top += self._layout_offset('score', 'y_offset') for i, line in enumerate(lines): width = draw.textlength(line, font=font) + x = (self.display_width - width) // 2 + self._layout_offset('score', 'x_offset') self._draw_text_with_outline( - draw, line, ((self.display_width - width) // 2, top + i * line_h), font + draw, line, (x, top + i * line_h), font, + fill=self._element_color('detail_text') ) def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: @@ -690,6 +813,58 @@ def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: str(game.get("game_time", "") or ""), ) + def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: + """Draw the date and time around an upcoming card. + + Time top and date bottom by default; scroll_card.swap_date_time puts + the date on top instead. Skipped when the pair is stacked in the + middle, which would otherwise print them twice. + """ + if self._upcoming_center_mode() == "date_time": + return + + date_raw, time_raw = self._upcoming_date_and_time(game) + date_text = (self._format_game_date(date_raw, game) + if self._scroll_card_option("show_date", True) else "") + time_text = (self._format_game_time(time_raw) + if self._scroll_card_option("show_time", True) else "") + + if self._scroll_card_option("swap_date_time", False): + top_text, top_el, bottom_text, bottom_el = ( + date_text, 'date', time_text, 'time') + top_font = self.fonts.get('detail') or self.fonts['time'] + bottom_font = self.fonts['time'] + top_color, bottom_color = 'detail_text', 'period_text' + else: + top_text, top_el, bottom_text, bottom_el = ( + time_text, 'time', date_text, 'date') + top_font = self.fonts['time'] + bottom_font = self.fonts.get('detail') or self.fonts['time'] + top_color, bottom_color = 'period_text', 'detail_text' + + if top_text: + top_width = draw.textlength(top_text, font=top_font) + top_x = (self.display_width - top_width) // 2 + self._layout_offset(top_el, 'x_offset') + top_y = 1 + self._layout_offset(top_el, 'y_offset') + self._draw_text_with_outline( + draw, top_text, (top_x, top_y), top_font, + fill=self._element_color(top_color) + ) + + if bottom_text: + bottom_width = draw.textlength(bottom_text, font=bottom_font) + bottom_x = ((self.display_width - bottom_width) // 2 + + self._layout_offset(bottom_el, 'x_offset')) + # Measured, not a fixed -7: the detail font is 6px in most plugins + # but 10px in soccer and nrl, where "Sep 19" ran past the card. + ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] + bottom_y = (max(0, self.display_height - ink_bottom - 1) + + self._layout_offset(bottom_el, 'y_offset')) + self._draw_text_with_outline( + draw, bottom_text, (bottom_x, bottom_y), bottom_font, + fill=self._element_color(bottom_color) + ) + def _render_upcoming_game(self, game: Dict) -> Image.Image: """Render an upcoming baseball game card.""" try: @@ -708,9 +883,11 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: # Logos (tighter fit) logo_slot = self._logo_slot_width() - away_x = (logo_slot - away_logo.width) // 2 + away_x = ((logo_slot - away_logo.width) // 2 + + self._layout_offset('away_logo', 'x_offset')) main_img.paste(away_logo, (away_x, center_y - away_logo.height // 2), away_logo) - home_x = (self.display_width - logo_slot) + (logo_slot - home_logo.width) // 2 + home_x = ((self.display_width - logo_slot) + (logo_slot - home_logo.width) // 2 + + self._layout_offset('home_logo', 'x_offset')) main_img.paste(home_logo, (home_x, center_y - home_logo.height // 2), home_logo) # Game time/date from start_time @@ -729,26 +906,12 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: except (ValueError, AttributeError): game_time = start_time[:10] if len(start_time) > 10 else start_time - game_date = self._format_game_date(game_date) - - # Matches the other sports' cards: VS (or the stacked date/time) - # in the middle, time top-center and date bottom-center. - self._draw_upcoming_center(draw, dict(game, game_date=game_date, - game_time=game_time)) - if self._upcoming_center_mode() == "vs": - if game_time: - time_width = draw.textlength(game_time, font=self.fonts['time']) - self._draw_text_with_outline( - draw, game_time, - ((self.display_width - time_width) // 2, 1), self.fonts['time']) - if game_date: - date_font = self.fonts.get('detail') or self.fonts['time'] - date_width = draw.textlength(game_date, font=date_font) - date_bottom = draw.textbbox((0, 0), game_date, font=date_font)[3] - self._draw_text_with_outline( - draw, game_date, - ((self.display_width - date_width) // 2, - max(0, self.display_height - date_bottom - 1)), date_font) + # Same card as the other sports: the middle (VS, or the date and + # time stacked), then the surrounding date/time. Both honour the + # scroll_card settings and the customization.layout offsets. + upcoming = dict(game, game_date=game_date, game_time=game_time) + self._draw_upcoming_center(draw, upcoming) + self._draw_upcoming_game_status(draw, upcoming) # Records at bottom corners self._draw_records(draw, game) diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index 811a5356..f60e13e2 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -33,7 +33,7 @@ { "version": "1.24.0", "released": "2026-08-06", - "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged.", + "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged. Adds a fuller set of scroll_card settings: vs_text (VS, @, at, ...), date_format now covering abbrev/numeric/day_first/numeric_day_first/weekday, time_format 12h or 24h, show_date, show_time, swap_date_time, upcoming_center gains a 'none' option, and center_gap_ratio/min/max for the automatic gap. Each customization text element gains text_color, and the customization.layout X/Y offsets are now honoured by the scroll/Vegas card -- the schema advertised them but only the full-screen scoreboard read them before. The away team is drawn on the left and the home team on the right, so \"at\" and \"@\" read correctly as \"away at home\".", "ledmatrix_min_version": "2.0.0" }, { diff --git a/plugins/basketball-scoreboard/config_schema.json b/plugins/basketball-scoreboard/config_schema.json index 858d5750..35585c31 100644 --- a/plugins/basketball-scoreboard/config_schema.json +++ b/plugins/basketball-scoreboard/config_schema.json @@ -13,29 +13,95 @@ "upcoming_center": { "type": "string", "title": "Middle of an Upcoming Card", - "description": "What to show between the two logos before a game starts: VS, or the date and time stacked in the middle. Upcoming games never show a score, since the game has not been played.", + "description": "What to show between the two logos before a game starts. Upcoming games never show a score, since the game has not been played.", "enum": [ "vs", - "date_time" + "date_time", + "none" ], "default": "vs" }, + "vs_text": { + "type": "string", + "title": "Matchup Separator", + "description": "Text drawn between the two teams, e.g. VS, @, at, v. The away team is always on the left and the home team on the right, so \"at\" and \"@\" read correctly as \"away at home\". Leave blank to draw nothing.", + "default": "VS", + "maxLength": 4 + }, "date_format": { "type": "string", "title": "Date Format", - "description": "How to write the date on an upcoming card: abbrev shows \"Sep 19\", numeric shows \"9/19\".", + "description": "How to write the date: abbrev \"Sep 19\", numeric \"9/19\", day_first \"19 Sep\", numeric_day_first \"19/9\", weekday \"Fri Sep 19\".", "enum": [ "abbrev", - "numeric" + "numeric", + "day_first", + "numeric_day_first", + "weekday" ], "default": "abbrev" }, + "time_format": { + "type": "string", + "title": "Time Format", + "description": "12h shows \"7:00PM\"; 24h shows \"19:00\".", + "enum": [ + "12h", + "24h" + ], + "default": "12h" + }, + "show_date": { + "type": "boolean", + "title": "Show Date", + "description": "Draw the date on upcoming cards.", + "default": true + }, + "show_time": { + "type": "boolean", + "title": "Show Time", + "description": "Draw the start time on upcoming cards.", + "default": true + }, + "swap_date_time": { + "type": "boolean", + "title": "Swap Date and Time", + "description": "Put the date on top and the time along the bottom instead of the default.", + "default": false + }, "center_gap": { "type": "integer", "title": "Center Gap", - "description": "Pixels kept clear down the middle of a card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", + "description": "Pixels kept clear down the middle so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. 0 restores the old edge-to-edge logos.", "minimum": 0, "maximum": 64 + }, + "center_gap_ratio": { + "type": "number", + "title": "Center Gap Ratio", + "description": "Fraction of card width used for the centre gap when it is not pinned.", + "minimum": 0.0, + "maximum": 0.6, + "default": 0.28, + "x-advanced": true + }, + "center_gap_min": { + "type": "integer", + "title": "Center Gap Minimum", + "description": "Lower bound in pixels for the scaled centre gap.", + "minimum": 0, + "maximum": 64, + "default": 22, + "x-advanced": true + }, + "center_gap_max": { + "type": "integer", + "title": "Center Gap Maximum", + "description": "Upper bound in pixels for the scaled centre gap.", + "minimum": 0, + "maximum": 96, + "default": 40, + "x-advanced": true } } }, @@ -154,21 +220,30 @@ "live_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, "recent_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, "upcoming_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } @@ -386,7 +461,7 @@ "type": "number", "minimum": 60, "maximum": 600, - "description": "Max duration for NBA games" + "description": "Max duration for NBA games" }, "modes": { "type": "object", @@ -454,11 +529,14 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type for NBA. If not set, uses dynamic calculation (total_games × per_game_duration).", + "description": "Control total duration for each mode type for NBA. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", "properties": { "recent_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -466,7 +544,10 @@ }, "upcoming_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -526,21 +607,30 @@ "live_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, "recent_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, "upcoming_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } @@ -826,11 +916,14 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type for WNBA. If not set, uses dynamic calculation (total_games × per_game_duration).", + "description": "Control total duration for each mode type for WNBA. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", "properties": { "recent_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -838,7 +931,10 @@ }, "upcoming_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -898,21 +994,30 @@ "live_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, "recent_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, "upcoming_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } @@ -1236,11 +1341,14 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type for NCAA Men's Basketball. If not set, uses dynamic calculation (total_games × per_game_duration).", + "description": "Control total duration for each mode type for NCAA Men's Basketball. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", "properties": { "recent_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -1248,7 +1356,10 @@ }, "upcoming_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -1308,21 +1419,30 @@ "live_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, "recent_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, "upcoming_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } @@ -1646,11 +1766,14 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type for NCAA Women's Basketball. If not set, uses dynamic calculation (total_games × per_game_duration).", + "description": "Control total duration for each mode type for NCAA Women's Basketball. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", "properties": { "recent_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -1658,7 +1781,10 @@ }, "upcoming_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -1669,422 +1795,585 @@ } }, "customization": { - "type": "object", - "title": "Display Customization", - "description": "Customize fonts for different text elements on the scoreboard", - "properties": { - "score_text": { - "type": "object", - "title": "Game Score", - "description": "Font settings for the game score display", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "title": "Font Family", - "description": "Select the font to use for scores", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "PressStart2P-Regular.ttf" + "type": "object", + "title": "Display Customization", + "description": "Customize fonts for different text elements on the scoreboard", + "properties": { + "score_text": { + "type": "object", + "title": "Game Score", + "description": "Font settings for the game score display", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "title": "Font Family", + "description": "Select the font to use for scores", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 10 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the score text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "font_size": { - "x-advanced": true, - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 10 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": ["font", "font_size"], - "additionalProperties": false - }, - "period_text": { - "type": "object", - "title": "Period/Clock", - "description": "Font settings for period, quarter, and clock text", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "PressStart2P-Regular.ttf" + "period_text": { + "type": "object", + "title": "Period/Clock", + "description": "Font settings for period, quarter, and clock text", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 8 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the period text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "font_size": { - "x-advanced": true, - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 8 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": ["font", "font_size"], - "additionalProperties": false - }, - "team_name": { - "type": "object", - "title": "Team Names", - "description": "Font settings for team name abbreviations", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "PressStart2P-Regular.ttf" + "team_name": { + "type": "object", + "title": "Team Names", + "description": "Font settings for team name abbreviations", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 8 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the team name on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "font_size": { - "x-advanced": true, - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 8 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": ["font", "font_size"], - "additionalProperties": false - }, - "status_text": { - "type": "object", - "title": "Status Messages", - "description": "Font settings for status text (e.g., 'Next Game', 'Final')", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "4x6-font.ttf" + "status_text": { + "type": "object", + "title": "Status Messages", + "description": "Font settings for status text (e.g., 'Next Game', 'Final')", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "4x6-font.ttf" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 6 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the status text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "font_size": { - "x-advanced": true, - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 6 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": ["font", "font_size"], - "additionalProperties": false - }, - "detail_text": { - "type": "object", - "title": "Details/Odds", - "description": "Font settings for odds and other detail information", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "4x6-font.ttf" + "detail_text": { + "type": "object", + "title": "Details/Odds", + "description": "Font settings for odds and other detail information", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "4x6-font.ttf" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 6 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the detail text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "font_size": { - "x-advanced": true, - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 6 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": ["font", "font_size"], - "additionalProperties": false - }, - "rank_text": { - "type": "object", - "title": "Rankings", - "description": "Font settings for ranking displays", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "PressStart2P-Regular.ttf" + "rank_text": { + "type": "object", + "title": "Rankings", + "description": "Font settings for ranking displays", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 10 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the rank text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "font_size": { - "x-advanced": true, - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 10 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": ["font", "font_size"], - "additionalProperties": false - }, - "layout": { - "type": "object", - "title": "Layout Positioning", - "description": "Adjust X,Y coordinate offsets for elements. Values are relative to default positions. Use negative values to move left/up, positive to move right/down.", - "properties": { - "home_logo": { - "type": "object", - "title": "Home Team Logo", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "layout": { + "type": "object", + "title": "Layout Positioning", + "description": "Adjust X,Y coordinate offsets for elements. Values are relative to default positions. Use negative values to move left/up, positive to move right/down.", + "properties": { + "home_logo": { + "type": "object", + "title": "Home Team Logo", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "away_logo": { - "type": "object", - "title": "Away Team Logo", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "away_logo": { + "type": "object", + "title": "Away Team Logo", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "score": { - "type": "object", - "title": "Game Score", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" + "score": { + "type": "object", + "title": "Game Score", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from center (default: 0)" + } }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from center (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "status": { - "type": "object", - "title": "Game Status", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "status": { + "type": "object", + "title": "Game Status", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "record": { - "type": "object", - "title": "Team Records", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "record": { + "type": "object", + "title": "Team Records", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "ranking": { - "type": "object", - "title": "Team Rankings", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "ranking": { + "type": "object", + "title": "Team Rankings", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false + "odds": { + "type": "object", + "title": "Betting Odds", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + } }, - "odds": { - "type": "object", - "title": "Betting Odds", - "properties": { - "x_offset": { - "x-advanced": true, + "x-propertyOrder": [ + "home_logo", + "away_logo", + "score", + "status", + "record", + "ranking", + "odds" + ], + "additionalProperties": false + }, + "favorite_result_colors": { + "type": "object", + "title": "Favorite Team Result Colors", + "description": "Color the final score of a recent game by how your favorite team did. Most useful in scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not tell you who won.", + "properties": { + "enabled": { + "type": "boolean", + "title": "Color Scores by Result", + "description": "Show the final score in green when your favorite team won and red when it lost. Games without exactly one favorite team - neither side, or both - keep the normal score color.", + "default": false + }, + "win_color": { + "type": "array", + "title": "Win Color", + "description": "Score color [R, G, B] when your favorite team won", + "x-widget": "color-picker", + "items": { "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "minimum": 0, + "maximum": 255 }, - "y_offset": { - "x-advanced": true, + "minItems": 3, + "maxItems": 3, + "default": [ + 0, + 255, + 0 + ], + "x-advanced": true + }, + "loss_color": { + "type": "array", + "title": "Loss Color", + "description": "Score color [R, G, B] when your favorite team lost", + "x-widget": "color-picker", + "items": { "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } - }, - "additionalProperties": false - } - }, - "x-propertyOrder": ["home_logo", "away_logo", "score", "status", "record", "ranking", "odds"], - "additionalProperties": false - }, - "favorite_result_colors": { - "type": "object", - "title": "Favorite Team Result Colors", - "description": "Color the final score of a recent game by how your favorite team did. Most useful in scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not tell you who won.", - "properties": { - "enabled": { - "type": "boolean", - "title": "Color Scores by Result", - "description": "Show the final score in green when your favorite team won and red when it lost. Games without exactly one favorite team - neither side, or both - keep the normal score color.", - "default": false - }, - "win_color": { - "type": "array", - "title": "Win Color", - "description": "Score color [R, G, B] when your favorite team won", - "x-widget": "color-picker", - "items": { - "type": "integer", - "minimum": 0, - "maximum": 255 - }, - "minItems": 3, - "maxItems": 3, - "default": [0, 255, 0], - "x-advanced": true - }, - "loss_color": { - "type": "array", - "title": "Loss Color", - "description": "Score color [R, G, B] when your favorite team lost", - "x-widget": "color-picker", - "items": { - "type": "integer", - "minimum": 0, - "maximum": 255 - }, - "minItems": 3, - "maxItems": 3, - "default": [255, 0, 0], - "x-advanced": true + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 0, + 0 + ], + "x-advanced": true + }, + "tie_color": { + "type": "array", + "title": "Tie Color", + "description": "Score color [R, G, B] when the game ended level", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 200, + 0 + ], + "x-advanced": true + } }, - "tie_color": { - "type": "array", - "title": "Tie Color", - "description": "Score color [R, G, B] when the game ended level", - "x-widget": "color-picker", - "items": { - "type": "integer", - "minimum": 0, - "maximum": 255 - }, - "minItems": 3, - "maxItems": 3, - "default": [255, 200, 0], - "x-advanced": true - } - }, - "x-propertyOrder": [ - "enabled", - "win_color", - "loss_color", - "tie_color" - ], - "additionalProperties": false - } - }, - "x-propertyOrder": ["score_text", "period_text", "team_name", "status_text", "detail_text", "rank_text", "layout", "favorite_result_colors"], - "additionalProperties": false - } + "x-propertyOrder": [ + "enabled", + "win_color", + "loss_color", + "tie_color" + ], + "additionalProperties": false + } + }, + "x-propertyOrder": [ + "score_text", + "period_text", + "team_name", + "status_text", + "detail_text", + "rank_text", + "layout", + "favorite_result_colors" + ], + "additionalProperties": false + } }, "additionalProperties": false, - "required": ["enabled"], + "required": [ + "enabled" + ], "x-propertyOrder": [ "enabled", "display_duration", diff --git a/plugins/basketball-scoreboard/game_renderer.py b/plugins/basketball-scoreboard/game_renderer.py index 187d4d7b..0d121da2 100644 --- a/plugins/basketball-scoreboard/game_renderer.py +++ b/plugins/basketball-scoreboard/game_renderer.py @@ -478,13 +478,17 @@ def render_game_card( # Draw logos — each centered within a slot on its side; cap at half the card # width so home_slot_start stays non-negative on square/tall displays logo_slot = self._logo_slot_width() - away_x = (logo_slot - away_logo.width) // 2 - away_y = center_y - (away_logo.height // 2) + away_x = ((logo_slot - away_logo.width) // 2 + + self._layout_offset('away_logo', 'x_offset')) + away_y = (center_y - (away_logo.height // 2) + + self._layout_offset('away_logo', 'y_offset')) main_img.paste(away_logo, (away_x, away_y), away_logo) home_slot_start = self.display_width - logo_slot - home_x = home_slot_start + (logo_slot - home_logo.width) // 2 - home_y = center_y - (home_logo.height // 2) + home_x = (home_slot_start + (logo_slot - home_logo.width) // 2 + + self._layout_offset('home_logo', 'x_offset')) + home_y = (center_y - (home_logo.height // 2) + + self._layout_offset('home_logo', 'y_offset')) main_img.paste(home_logo, (home_x, home_y), home_logo) # Draw scores (centered) — only once a game has started. Upcoming games @@ -494,8 +498,10 @@ def render_game_card( away_score = str(game.get("away_score", "0")) score_text = f"{away_score}-{home_score}" score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 + score_x = ((self.display_width - score_width) // 2 + + self._layout_offset('score', 'x_offset')) + score_y = ((self.display_height // 2) - 3 + + self._layout_offset('score', 'y_offset')) self._draw_text_with_outline( draw_overlay, score_text, (score_x, score_y), self.fonts['score'], fill=self._score_color_for(game, game_type) @@ -571,22 +577,23 @@ def _draw_recent_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['detail']) # ------------------------------------------------------------------ - # Scroll/Vegas card options -- config["scroll_card"]. + # Scroll/Vegas card options -- config["scroll_card"], plus the shared + # customization.layout offsets and per-element colours. # # These only affect the cards this renderer builds, which are used by # scroll_display.py and scroll_display_legacy.py alone. The full-screen # scorebug is drawn elsewhere and is deliberately left untouched. # ------------------------------------------------------------------ - # Middle strip kept clear of logos so the score / "VS" is never drawn on - # top of them. 0.28 of a 128px card clears "1-2" (30px) with room spare. CENTER_GAP_RATIO: ClassVar[float] = 0.28 - # 22 so "VS" (20px) still clears the logos on the narrowest 64px card. CENTER_GAP_MIN_PX: ClassVar[int] = 22 CENTER_GAP_MAX_PX: ClassVar[int] = 40 _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ) + _WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = ( + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", + ) def _logo_cache_key(self, name: str) -> str: """Cache key scoped to the logo slot. @@ -603,16 +610,58 @@ def _scroll_card_option(self, key: str, default: Any = None) -> Any: return block.get(key) return default + def _layout_offset(self, element: str, axis: str, default: int = 0) -> int: + """X/Y nudge for one element, from customization.layout. + + Same block the full-screen scorebug reads (sports.py + _get_layout_offset), so a nudge configured in the web UI now moves + the element on the scroll/Vegas card too -- previously the schema + advertised these offsets but this renderer ignored them. + """ + try: + layout = (self.config or {}).get("customization", {}).get("layout", {}) + value = (layout.get(element) or {}).get(axis, default) + if isinstance(value, bool): + return default + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + return int(float(value)) + except (TypeError, ValueError): + pass + return default + + def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): + """Per-element text colour from customization..text_color.""" + try: + cfg = (self.config or {}).get("customization", {}).get(element, {}) + value = cfg.get("text_color") + if isinstance(value, (list, tuple)) and len(value) == 3: + return tuple(max(0, min(255, int(c))) for c in value) + if isinstance(value, str) and value.startswith("#") and len(value) == 7: + return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) + except (TypeError, ValueError): + pass + return default + def _center_gap_width(self) -> int: """Width of the middle strip kept clear of logos. - ``scroll_card.center_gap`` overrides it; 0 restores edge-to-edge logos. + ``scroll_card.center_gap`` pins it outright; otherwise it scales with + the card width between the configurable min and max. 0 restores + edge-to-edge logos. """ configured = self._scroll_card_option("center_gap") if isinstance(configured, (int, float)) and configured >= 0: return int(configured) - scaled = round(self.display_width * self.CENTER_GAP_RATIO) - return int(max(self.CENTER_GAP_MIN_PX, min(self.CENTER_GAP_MAX_PX, scaled))) + ratio = self._scroll_card_option("center_gap_ratio", self.CENTER_GAP_RATIO) + low = self._scroll_card_option("center_gap_min", self.CENTER_GAP_MIN_PX) + high = self._scroll_card_option("center_gap_max", self.CENTER_GAP_MAX_PX) + try: + scaled = round(self.display_width * float(ratio)) + return int(max(int(low), min(int(high), scaled))) + except (TypeError, ValueError): + return self.CENTER_GAP_MIN_PX def _logo_slot_width(self) -> int: """Per-side logo slot, leaving the center gap clear. @@ -625,50 +674,124 @@ def _logo_slot_width(self) -> int: return max(8, min(self.display_height, available)) def _upcoming_center_mode(self) -> str: - """What sits in the middle of an upcoming card: 'vs' or 'date_time'.""" + """Middle of an upcoming card: 'vs', 'date_time' or 'none'.""" mode = str(self._scroll_card_option("upcoming_center", "vs") or "vs").lower() - return mode if mode in ("vs", "date_time") else "vs" + return mode if mode in ("vs", "date_time", "none") else "vs" + + def _vs_text(self) -> str: + """Separator drawn between the teams -- "VS", "@", "at", anything.""" + return str(self._scroll_card_option("vs_text", "VS")) - def _format_game_date(self, date_text: str) -> str: - """Render a date as "Sep 19" (default) or "9/19" (``date_format``).""" + def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: + """Format an upcoming card's date per scroll_card.date_format.""" raw = str(date_text or "").strip() - if not raw or self._scroll_card_option("date_format", "abbrev") == "numeric": + if not raw: + return "" + fmt = str(self._scroll_card_option("date_format", "abbrev") or "abbrev") + if fmt == "numeric": return raw parts = raw.replace("-", "/").split("/") - if len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): - month = int(parts[0]) - if 1 <= month <= 12: - return f"{self._MONTH_ABBR[month - 1]} {int(parts[1])}" - return raw + if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): + return raw + month, day = int(parts[0]), int(parts[1]) + if not 1 <= month <= 12: + return raw + name = self._MONTH_ABBR[month - 1] + if fmt == "numeric_day_first": + return f"{day}/{month}" + if fmt == "day_first": + return f"{day} {name}" + if fmt == "weekday": + weekday = self._weekday_for(game) + return f"{weekday} {name} {day}" if weekday else f"{name} {day}" + return f"{name} {day}" + + def _weekday_for(self, game: Optional[Dict]) -> str: + """Weekday abbreviation from the game's start time, or ''.""" + if not game: + return "" + raw = game.get("start_time_utc") or game.get("start_time") + if not raw: + return "" + try: + start = raw if isinstance(raw, datetime) else datetime.fromisoformat( + str(raw).replace("Z", "+00:00")) + return self._WEEKDAY_ABBR[start.astimezone(self._card_tzinfo()).weekday()] + except (ValueError, TypeError): + return "" + + def _card_tzinfo(self): + """Timezone for weekday/24h conversions; falls back to UTC.""" + try: + configured = (self.config or {}).get("timezone") + if configured: + return ZoneInfo(configured) + except Exception: + pass + return timezone.utc + + def _format_game_time(self, time_text: str) -> str: + """Return the time as-is (12h) or converted to 24h.""" + raw = str(time_text or "").strip() + if not raw or str(self._scroll_card_option("time_format", "12h")) != "24h": + return raw + cleaned = raw.upper().replace(" ", "") + meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" + if not meridiem: + return raw + try: + hh, _, mm = cleaned[:-2].partition(":") + hour, minute = int(hh), int(mm or 0) + except ValueError: + return raw + if not (0 <= hour <= 12 and 0 <= minute <= 59): + return raw + hour = hour % 12 + (12 if meridiem == "PM" else 0) + return f"{hour:02d}:{minute:02d}" def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: """Draw the middle of an upcoming card. Never a score: an upcoming game has not started, so the extractor's - 0-0 is noise. Either "VS" (default) or the date and time stacked. + 0-0 is noise. Either the VS text (default), the date and time stacked, + or nothing at all. """ - if self._upcoming_center_mode() == "vs": - vs_text = "VS" + mode = self._upcoming_center_mode() + if mode == "none": + return + + if mode == "vs": + vs_text = self._vs_text() + if not vs_text: + return vs_width = draw.textlength(vs_text, font=self.fonts['score']) - vs_x = (self.display_width - vs_width) // 2 - vs_y = (self.display_height // 2) - 3 + vs_x = (self.display_width - vs_width) // 2 + self._layout_offset('score', 'x_offset') + vs_y = (self.display_height // 2) - 3 + self._layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts['score'] + draw, vs_text, (vs_x, vs_y), self.fonts['score'], + fill=self._element_color('score_text') ) return date_text, time_text = self._upcoming_date_and_time(game) - date_text = self._format_game_date(date_text) - font = self.fonts.get('detail') or self.fonts['time'] - lines = [t for t in (date_text, time_text) if t] + lines = [] + if self._scroll_card_option("show_date", True): + lines.append(self._format_game_date(date_text, game)) + if self._scroll_card_option("show_time", True): + lines.append(self._format_game_time(time_text)) + lines = [t for t in lines if t] if not lines: return + font = self.fonts.get('detail') or self.fonts['time'] line_h = 7 top = (self.display_height // 2) - (len(lines) * line_h) // 2 + top += self._layout_offset('score', 'y_offset') for i, line in enumerate(lines): width = draw.textlength(line, font=font) + x = (self.display_width - width) // 2 + self._layout_offset('score', 'x_offset') self._draw_text_with_outline( - draw, line, ((self.display_width - width) // 2, top + i * line_h), font + draw, line, (x, top + i * line_h), font, + fill=self._element_color('detail_text') ) def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: @@ -679,34 +802,55 @@ def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: ) def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: - """Draw date/time around an upcoming card: time top, date bottom. + """Draw the date and time around an upcoming card. - Skipped when the date and time are stacked in the middle instead -- - drawing both would print them twice. + Time top and date bottom by default; scroll_card.swap_date_time puts + the date on top instead. Skipped when the pair is stacked in the + middle, which would otherwise print them twice. """ - if self._upcoming_center_mode() != "vs": + if self._upcoming_center_mode() == "date_time": return - date_text, time_text = self._upcoming_date_and_time(game) - date_text = self._format_game_date(date_text) - - if time_text: - time_width = draw.textlength(time_text, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 + date_raw, time_raw = self._upcoming_date_and_time(game) + date_text = (self._format_game_date(date_raw, game) + if self._scroll_card_option("show_date", True) else "") + time_text = (self._format_game_time(time_raw) + if self._scroll_card_option("show_time", True) else "") + + if self._scroll_card_option("swap_date_time", False): + top_text, top_el, bottom_text, bottom_el = ( + date_text, 'date', time_text, 'time') + top_font = self.fonts.get('detail') or self.fonts['time'] + bottom_font = self.fonts['time'] + top_color, bottom_color = 'detail_text', 'period_text' + else: + top_text, top_el, bottom_text, bottom_el = ( + time_text, 'time', date_text, 'date') + top_font = self.fonts['time'] + bottom_font = self.fonts.get('detail') or self.fonts['time'] + top_color, bottom_color = 'period_text', 'detail_text' + + if top_text: + top_width = draw.textlength(top_text, font=top_font) + top_x = (self.display_width - top_width) // 2 + self._layout_offset(top_el, 'x_offset') + top_y = 1 + self._layout_offset(top_el, 'y_offset') self._draw_text_with_outline( - draw, time_text, (time_x, 1), self.fonts['time'] + draw, top_text, (top_x, top_y), top_font, + fill=self._element_color(top_color) ) - if date_text: - date_font = self.fonts.get('detail') or self.fonts['time'] - date_width = draw.textlength(date_text, font=date_font) - date_x = (self.display_width - date_width) // 2 + if bottom_text: + bottom_width = draw.textlength(bottom_text, font=bottom_font) + bottom_x = ((self.display_width - bottom_width) // 2 + + self._layout_offset(bottom_el, 'x_offset')) # Measured, not a fixed -7: the detail font is 6px in most plugins - # but 10px in soccer and nrl, where "Sep 19" ran 5px past the card. - date_bottom = draw.textbbox((0, 0), date_text, font=date_font)[3] - date_y = max(0, self.display_height - date_bottom - 1) + # but 10px in soccer and nrl, where "Sep 19" ran past the card. + ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] + bottom_y = (max(0, self.display_height - ink_bottom - 1) + + self._layout_offset(bottom_el, 'y_offset')) self._draw_text_with_outline( - draw, date_text, (date_x, date_y), date_font + draw, bottom_text, (bottom_x, bottom_y), bottom_font, + fill=self._element_color(bottom_color) ) def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index b71aaecf..24a29f93 100644 --- a/plugins/basketball-scoreboard/manifest.json +++ b/plugins/basketball-scoreboard/manifest.json @@ -21,7 +21,7 @@ { "version": "1.12.0", "released": "2026-08-06", - "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged.", + "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged. Adds a fuller set of scroll_card settings: vs_text (VS, @, at, ...), date_format now covering abbrev/numeric/day_first/numeric_day_first/weekday, time_format 12h or 24h, show_date, show_time, swap_date_time, upcoming_center gains a 'none' option, and center_gap_ratio/min/max for the automatic gap. Each customization text element gains text_color, and the customization.layout X/Y offsets are now honoured by the scroll/Vegas card -- the schema advertised them but only the full-screen scoreboard read them before. The away team is drawn on the left and the home team on the right, so \"at\" and \"@\" read correctly as \"away at home\".", "ledmatrix_min_version": "2.0.0" }, { diff --git a/plugins/football-scoreboard/config_schema.json b/plugins/football-scoreboard/config_schema.json index ff8301b4..f69b2534 100644 --- a/plugins/football-scoreboard/config_schema.json +++ b/plugins/football-scoreboard/config_schema.json @@ -13,29 +13,95 @@ "upcoming_center": { "type": "string", "title": "Middle of an Upcoming Card", - "description": "What to show between the two logos before a game starts: VS, or the date and time stacked in the middle. Upcoming games never show a score, since the game has not been played.", + "description": "What to show between the two logos before a game starts. Upcoming games never show a score, since the game has not been played.", "enum": [ "vs", - "date_time" + "date_time", + "none" ], "default": "vs" }, + "vs_text": { + "type": "string", + "title": "Matchup Separator", + "description": "Text drawn between the two teams, e.g. VS, @, at, v. The away team is always on the left and the home team on the right, so \"at\" and \"@\" read correctly as \"away at home\". Leave blank to draw nothing.", + "default": "VS", + "maxLength": 4 + }, "date_format": { "type": "string", "title": "Date Format", - "description": "How to write the date on an upcoming card: abbrev shows \"Sep 19\", numeric shows \"9/19\".", + "description": "How to write the date: abbrev \"Sep 19\", numeric \"9/19\", day_first \"19 Sep\", numeric_day_first \"19/9\", weekday \"Fri Sep 19\".", "enum": [ "abbrev", - "numeric" + "numeric", + "day_first", + "numeric_day_first", + "weekday" ], "default": "abbrev" }, + "time_format": { + "type": "string", + "title": "Time Format", + "description": "12h shows \"7:00PM\"; 24h shows \"19:00\".", + "enum": [ + "12h", + "24h" + ], + "default": "12h" + }, + "show_date": { + "type": "boolean", + "title": "Show Date", + "description": "Draw the date on upcoming cards.", + "default": true + }, + "show_time": { + "type": "boolean", + "title": "Show Time", + "description": "Draw the start time on upcoming cards.", + "default": true + }, + "swap_date_time": { + "type": "boolean", + "title": "Swap Date and Time", + "description": "Put the date on top and the time along the bottom instead of the default.", + "default": false + }, "center_gap": { "type": "integer", "title": "Center Gap", - "description": "Pixels kept clear down the middle of a card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", + "description": "Pixels kept clear down the middle so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. 0 restores the old edge-to-edge logos.", "minimum": 0, "maximum": 64 + }, + "center_gap_ratio": { + "type": "number", + "title": "Center Gap Ratio", + "description": "Fraction of card width used for the centre gap when it is not pinned.", + "minimum": 0.0, + "maximum": 0.6, + "default": 0.28, + "x-advanced": true + }, + "center_gap_min": { + "type": "integer", + "title": "Center Gap Minimum", + "description": "Lower bound in pixels for the scaled centre gap.", + "minimum": 0, + "maximum": 64, + "default": 22, + "x-advanced": true + }, + "center_gap_max": { + "type": "integer", + "title": "Center Gap Maximum", + "description": "Upper bound in pixels for the scaled centre gap.", + "minimum": 0, + "maximum": 96, + "default": 40, + "x-advanced": true } } }, @@ -117,21 +183,30 @@ "live_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, "recent_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, "upcoming_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } @@ -485,21 +560,30 @@ "live_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, "recent_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, "upcoming_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } @@ -806,9 +890,12 @@ "layout_mode": { "x-advanced": true, "type": "string", - "enum": ["classic", "adaptive"], + "enum": [ + "classic", + "adaptive" + ], "default": "classic", - "description": "Layout engine. 'classic' is the original fixed layout (unchanged). 'adaptive' (beta) scales fonts, logos, and element regions to the panel size — content grows on large panels and degrades gracefully on small ones. Your customization fonts and x/y offsets still apply in adaptive mode. Requires LEDMatrix core with the adaptive layout system; falls back to classic on older cores. Switch back to 'classic' at any time to restore the original rendering." + "description": "Layout engine. 'classic' is the original fixed layout (unchanged). 'adaptive' (beta) scales fonts, logos, and element regions to the panel size \u2014 content grows on large panels and degrades gracefully on small ones. Your customization fonts and x/y offsets still apply in adaptive mode. Requires LEDMatrix core with the adaptive layout system; falls back to classic on older cores. Switch back to 'classic' at any time to restore the original rendering." }, "timezone": { "x-advanced": true, @@ -817,503 +904,669 @@ "description": "IANA timezone used to display event start times (e.g. America/Chicago). Leave blank to follow the LEDMatrix global timezone, or the system timezone if none is set. A bare \"UTC\" here is treated as a leftover from the old write-back bug and ignored when your global or system timezone disagrees \u2014 use \"Etc/UTC\" if you really want UTC." }, "customization": { - "type": "object", - "title": "Display Customization", - "description": "Customize fonts for different text elements on the scoreboard", - "properties": { - "score_text": { - "type": "object", - "title": "Game Score", - "description": "Font settings for the game score display", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "title": "Font Family", - "description": "Select the font to use for scores", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "PressStart2P-Regular.ttf" + "type": "object", + "title": "Display Customization", + "description": "Customize fonts for different text elements on the scoreboard", + "properties": { + "score_text": { + "type": "object", + "title": "Game Score", + "description": "Font settings for the game score display", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "title": "Font Family", + "description": "Select the font to use for scores", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 10 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the score text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "font_size": { - "x-advanced": true, - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 10 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": ["font", "font_size"], - "additionalProperties": false - }, - "period_text": { - "type": "object", - "title": "Period/Clock", - "description": "Font settings for period, quarter, and clock text", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "PressStart2P-Regular.ttf" + "period_text": { + "type": "object", + "title": "Period/Clock", + "description": "Font settings for period, quarter, and clock text", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 8 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the period text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "font_size": { - "x-advanced": true, - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 8 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": ["font", "font_size"], - "additionalProperties": false - }, - "team_name": { - "type": "object", - "title": "Team Names", - "description": "Font settings for team name abbreviations", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "PressStart2P-Regular.ttf" + "team_name": { + "type": "object", + "title": "Team Names", + "description": "Font settings for team name abbreviations", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 8 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the team name on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "font_size": { - "x-advanced": true, - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 8 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": ["font", "font_size"], - "additionalProperties": false - }, - "status_text": { - "type": "object", - "title": "Status Messages", - "description": "Font settings for status text (e.g., 'Next Game', 'Final')", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "4x6-font.ttf" + "status_text": { + "type": "object", + "title": "Status Messages", + "description": "Font settings for status text (e.g., 'Next Game', 'Final')", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "4x6-font.ttf" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 6 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the status text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "font_size": { - "x-advanced": true, - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 6 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": ["font", "font_size"], - "additionalProperties": false - }, - "detail_text": { - "type": "object", - "title": "Details/Odds", - "description": "Font settings for odds and other detail information", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "4x6-font.ttf" + "detail_text": { + "type": "object", + "title": "Details/Odds", + "description": "Font settings for odds and other detail information", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "4x6-font.ttf" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 6 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the detail text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "font_size": { - "x-advanced": true, - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 6 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": ["font", "font_size"], - "additionalProperties": false - }, - "rank_text": { - "type": "object", - "title": "Rankings", - "description": "Font settings for ranking displays", - "properties": { - "font": { - "x-advanced": true, - "type": "string", - "title": "Font Family", - "description": "Select the font to use", - "enum": [ - "PressStart2P-Regular.ttf", - "4x6-font.ttf", - "5by7.regular.ttf", - "5x7.bdf", - "4x6.bdf", - "cozette.bdf" - ], - "default": "PressStart2P-Regular.ttf" + "rank_text": { + "type": "object", + "title": "Rankings", + "description": "Font settings for ranking displays", + "properties": { + "font": { + "x-advanced": true, + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "x-advanced": true, + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 10 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the rank text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "font_size": { - "x-advanced": true, - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 10 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": ["font", "font_size"], - "additionalProperties": false - }, - "layout": { - "type": "object", - "title": "Layout Positioning", - "description": "Adjust X,Y coordinate offsets for elements. Values are relative to default positions. Use negative values to move left/up, positive to move right/down.", - "properties": { - "home_logo": { - "type": "object", - "title": "Home Team Logo", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "layout": { + "type": "object", + "title": "Layout Positioning", + "description": "Adjust X,Y coordinate offsets for elements. Values are relative to default positions. Use negative values to move left/up, positive to move right/down.", + "properties": { + "home_logo": { + "type": "object", + "title": "Home Team Logo", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "away_logo": { - "type": "object", - "title": "Away Team Logo", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "away_logo": { + "type": "object", + "title": "Away Team Logo", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "score": { - "type": "object", - "title": "Game Score", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" + "score": { + "type": "object", + "title": "Game Score", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from center (default: 0)" + } }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from center (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "status_text": { - "type": "object", - "title": "Status/Period Text", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" + "status_text": { + "type": "object", + "title": "Status/Period Text", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from top (default: 0)" + } }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from top (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "date": { - "type": "object", - "title": "Game Date", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" + "date": { + "type": "object", + "title": "Game Date", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "time": { - "type": "object", - "title": "Game Time", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" + "time": { + "type": "object", + "title": "Game Time", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from date position (default: 0)" + } }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from date position (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "down_distance": { - "type": "object", - "title": "Down & Distance", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "down_distance": { + "type": "object", + "title": "Down & Distance", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "timeouts": { - "type": "object", - "title": "Timeouts", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "timeouts": { + "type": "object", + "title": "Timeouts", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "possession": { - "type": "object", - "title": "Possession Indicator", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "possession": { + "type": "object", + "title": "Possession Indicator", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } + "additionalProperties": false + }, + "records": { + "type": "object", + "title": "Records/Rankings", + "properties": { + "away_x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Away team record horizontal offset from left (default: 0)" + }, + "home_x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Home team record horizontal offset from right (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from bottom (default: 0)" + } + }, + "additionalProperties": false }, - "additionalProperties": false + "odds": { + "type": "object", + "title": "Betting Odds", + "properties": { + "x_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "x-advanced": true, + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + } }, - "records": { - "type": "object", - "title": "Records/Rankings", - "properties": { - "away_x_offset": { - "x-advanced": true, + "x-propertyOrder": [ + "home_logo", + "away_logo", + "score", + "status_text", + "date", + "time", + "down_distance", + "timeouts", + "possession", + "records", + "odds" + ], + "additionalProperties": false + }, + "favorite_result_colors": { + "type": "object", + "title": "Favorite Team Result Colors", + "description": "Color the final score of a recent game by how your favorite team did. Most useful in scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not tell you who won.", + "properties": { + "enabled": { + "type": "boolean", + "title": "Color Scores by Result", + "description": "Show the final score in green when your favorite team won and red when it lost. Games without exactly one favorite team - neither side, or both - keep the normal score color.", + "default": false + }, + "win_color": { + "type": "array", + "title": "Win Color", + "description": "Score color [R, G, B] when your favorite team won", + "x-widget": "color-picker", + "items": { "type": "integer", - "default": 0, - "description": "Away team record horizontal offset from left (default: 0)" + "minimum": 0, + "maximum": 255 }, - "home_x_offset": { - "x-advanced": true, + "minItems": 3, + "maxItems": 3, + "default": [ + 0, + 255, + 0 + ], + "x-advanced": true + }, + "loss_color": { + "type": "array", + "title": "Loss Color", + "description": "Score color [R, G, B] when your favorite team lost", + "x-widget": "color-picker", + "items": { "type": "integer", - "default": 0, - "description": "Home team record horizontal offset from right (default: 0)" + "minimum": 0, + "maximum": 255 }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from bottom (default: 0)" - } + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 0, + 0 + ], + "x-advanced": true }, - "additionalProperties": false - }, - "odds": { - "type": "object", - "title": "Betting Odds", - "properties": { - "x_offset": { - "x-advanced": true, + "tie_color": { + "type": "array", + "title": "Tie Color", + "description": "Score color [R, G, B] when the game ended level", + "x-widget": "color-picker", + "items": { "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "minimum": 0, + "maximum": 255 }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } - }, - "additionalProperties": false - } - }, - "x-propertyOrder": ["home_logo", "away_logo", "score", "status_text", "date", "time", "down_distance", "timeouts", "possession", "records", "odds"], - "additionalProperties": false - }, - "favorite_result_colors": { - "type": "object", - "title": "Favorite Team Result Colors", - "description": "Color the final score of a recent game by how your favorite team did. Most useful in scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not tell you who won.", - "properties": { - "enabled": { - "type": "boolean", - "title": "Color Scores by Result", - "description": "Show the final score in green when your favorite team won and red when it lost. Games without exactly one favorite team - neither side, or both - keep the normal score color.", - "default": false - }, - "win_color": { - "type": "array", - "title": "Win Color", - "description": "Score color [R, G, B] when your favorite team won", - "x-widget": "color-picker", - "items": { - "type": "integer", - "minimum": 0, - "maximum": 255 - }, - "minItems": 3, - "maxItems": 3, - "default": [0, 255, 0], - "x-advanced": true - }, - "loss_color": { - "type": "array", - "title": "Loss Color", - "description": "Score color [R, G, B] when your favorite team lost", - "x-widget": "color-picker", - "items": { - "type": "integer", - "minimum": 0, - "maximum": 255 - }, - "minItems": 3, - "maxItems": 3, - "default": [255, 0, 0], - "x-advanced": true + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 200, + 0 + ], + "x-advanced": true + } }, - "tie_color": { - "type": "array", - "title": "Tie Color", - "description": "Score color [R, G, B] when the game ended level", - "x-widget": "color-picker", - "items": { - "type": "integer", - "minimum": 0, - "maximum": 255 - }, - "minItems": 3, - "maxItems": 3, - "default": [255, 200, 0], - "x-advanced": true - } - }, - "x-propertyOrder": [ - "enabled", - "win_color", - "loss_color", - "tie_color" - ], - "additionalProperties": false - } - }, - "x-propertyOrder": ["score_text", "period_text", "team_name", "status_text", "detail_text", "rank_text", "layout", "favorite_result_colors"], - "additionalProperties": false - } + "x-propertyOrder": [ + "enabled", + "win_color", + "loss_color", + "tie_color" + ], + "additionalProperties": false + } + }, + "x-propertyOrder": [ + "score_text", + "period_text", + "team_name", + "status_text", + "detail_text", + "rank_text", + "layout", + "favorite_result_colors" + ], + "additionalProperties": false + } }, "additionalProperties": false, - "required": ["enabled"] + "required": [ + "enabled" + ] } - diff --git a/plugins/football-scoreboard/game_renderer.py b/plugins/football-scoreboard/game_renderer.py index 13484d05..f6f78053 100644 --- a/plugins/football-scoreboard/game_renderer.py +++ b/plugins/football-scoreboard/game_renderer.py @@ -562,13 +562,17 @@ def render_game_card( # Draw logos — each centered within a slot on its side, leaving the # centre gap clear so the score is never drawn on top of a logo. logo_slot = self._logo_slot_width() - away_x = (logo_slot - away_logo.width) // 2 - away_y = center_y - (away_logo.height // 2) + away_x = ((logo_slot - away_logo.width) // 2 + + self._layout_offset('away_logo', 'x_offset')) + away_y = (center_y - (away_logo.height // 2) + + self._layout_offset('away_logo', 'y_offset')) main_img.paste(away_logo, (away_x, away_y), away_logo) home_slot_start = self.display_width - logo_slot - home_x = home_slot_start + (logo_slot - home_logo.width) // 2 - home_y = center_y - (home_logo.height // 2) + home_x = (home_slot_start + (logo_slot - home_logo.width) // 2 + + self._layout_offset('home_logo', 'x_offset')) + home_y = (center_y - (home_logo.height // 2) + + self._layout_offset('home_logo', 'y_offset')) main_img.paste(home_logo, (home_x, home_y), home_logo) # Draw scores (centered) — only once a game has started. Upcoming games @@ -578,8 +582,10 @@ def render_game_card( away_score = str(game.get("away_score", "0")) score_text = f"{away_score}-{home_score}" score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 + score_x = ((self.display_width - score_width) // 2 + + self._layout_offset('score', 'x_offset')) + score_y = ((self.display_height // 2) - 3 + + self._layout_offset('score', 'y_offset')) self._draw_text_with_outline( draw_overlay, score_text, (score_x, score_y), self.fonts['score'], fill=self._score_color_for(game, game_type) @@ -805,9 +811,12 @@ def _render_game_card_adaptive(self, game: Dict[str, Any], self._draw_fit_outline(draw_overlay, score_fit, score_region, fill=self._score_color_for(game, game_type)) elif game_type == "upcoming" and self._upcoming_center_mode() == "vs": - vs_fit = self._fit_element('score', "VS", score_region, - ADAPTIVE_LADDER_HEADLINE) - self._draw_fit_outline(draw_overlay, vs_fit, score_region) + vs_text = self._vs_text() + if vs_text: + vs_fit = self._fit_element('score', vs_text, score_region, + ADAPTIVE_LADDER_HEADLINE) + self._draw_fit_outline(draw_overlay, vs_fit, score_region, + fill=self._element_color('score_text')) if game_type == "live": self._draw_live_status_adaptive(draw_overlay, game, regs) @@ -819,12 +828,18 @@ def _render_game_card_adaptive(self, game: Dict[str, Any], self._draw_fit_outline(draw_overlay, fit, self._region_for(regs.status_band, 'status_text')) self._draw_bottom_center_adaptive( - draw_overlay, self._format_game_date(game.get("game_date", "")), + draw_overlay, self._format_game_date(game.get("game_date", ""), game), regs, 'date') elif game_type == "upcoming": - game_date = self._format_game_date(game.get("game_date", "")) - game_time = game.get("game_time", "") - if self._upcoming_center_mode() != "vs": + game_date = (self._format_game_date(game.get("game_date", ""), game) + if self._scroll_card_option("show_date", True) else "") + game_time = (self._format_game_time(game.get("game_time", "")) + if self._scroll_card_option("show_time", True) else "") + if self._scroll_card_option("swap_date_time", False): + game_date, game_time = game_time, game_date + if self._upcoming_center_mode() == "none": + pass + elif self._upcoming_center_mode() != "vs": # Date and time stacked in the middle instead of top/bottom. stacked = " ".join(t for t in (game_date, game_time) if t) if stacked: @@ -1041,22 +1056,23 @@ def _draw_recent_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['detail']) # ------------------------------------------------------------------ - # Scroll/Vegas card options -- config["scroll_card"]. + # Scroll/Vegas card options -- config["scroll_card"], plus the shared + # customization.layout offsets and per-element colours. # # These only affect the cards this renderer builds, which are used by # scroll_display.py and scroll_display_legacy.py alone. The full-screen # scorebug is drawn elsewhere and is deliberately left untouched. # ------------------------------------------------------------------ - # Middle strip kept clear of logos so the score / "VS" is never drawn on - # top of them. 0.28 of a 128px card clears "1-2" (30px) with room spare. CENTER_GAP_RATIO: ClassVar[float] = 0.28 - # 22 so "VS" (20px) still clears the logos on the narrowest 64px card. CENTER_GAP_MIN_PX: ClassVar[int] = 22 CENTER_GAP_MAX_PX: ClassVar[int] = 40 _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ) + _WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = ( + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", + ) def _logo_cache_key(self, name: str) -> str: """Cache key scoped to the logo slot. @@ -1073,16 +1089,58 @@ def _scroll_card_option(self, key: str, default: Any = None) -> Any: return block.get(key) return default + def _layout_offset(self, element: str, axis: str, default: int = 0) -> int: + """X/Y nudge for one element, from customization.layout. + + Same block the full-screen scorebug reads (sports.py + _get_layout_offset), so a nudge configured in the web UI now moves + the element on the scroll/Vegas card too -- previously the schema + advertised these offsets but this renderer ignored them. + """ + try: + layout = (self.config or {}).get("customization", {}).get("layout", {}) + value = (layout.get(element) or {}).get(axis, default) + if isinstance(value, bool): + return default + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + return int(float(value)) + except (TypeError, ValueError): + pass + return default + + def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): + """Per-element text colour from customization..text_color.""" + try: + cfg = (self.config or {}).get("customization", {}).get(element, {}) + value = cfg.get("text_color") + if isinstance(value, (list, tuple)) and len(value) == 3: + return tuple(max(0, min(255, int(c))) for c in value) + if isinstance(value, str) and value.startswith("#") and len(value) == 7: + return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) + except (TypeError, ValueError): + pass + return default + def _center_gap_width(self) -> int: """Width of the middle strip kept clear of logos. - ``scroll_card.center_gap`` overrides it; 0 restores edge-to-edge logos. + ``scroll_card.center_gap`` pins it outright; otherwise it scales with + the card width between the configurable min and max. 0 restores + edge-to-edge logos. """ configured = self._scroll_card_option("center_gap") if isinstance(configured, (int, float)) and configured >= 0: return int(configured) - scaled = round(self.display_width * self.CENTER_GAP_RATIO) - return int(max(self.CENTER_GAP_MIN_PX, min(self.CENTER_GAP_MAX_PX, scaled))) + ratio = self._scroll_card_option("center_gap_ratio", self.CENTER_GAP_RATIO) + low = self._scroll_card_option("center_gap_min", self.CENTER_GAP_MIN_PX) + high = self._scroll_card_option("center_gap_max", self.CENTER_GAP_MAX_PX) + try: + scaled = round(self.display_width * float(ratio)) + return int(max(int(low), min(int(high), scaled))) + except (TypeError, ValueError): + return self.CENTER_GAP_MIN_PX def _logo_slot_width(self) -> int: """Per-side logo slot, leaving the center gap clear. @@ -1095,50 +1153,124 @@ def _logo_slot_width(self) -> int: return max(8, min(self.display_height, available)) def _upcoming_center_mode(self) -> str: - """What sits in the middle of an upcoming card: 'vs' or 'date_time'.""" + """Middle of an upcoming card: 'vs', 'date_time' or 'none'.""" mode = str(self._scroll_card_option("upcoming_center", "vs") or "vs").lower() - return mode if mode in ("vs", "date_time") else "vs" + return mode if mode in ("vs", "date_time", "none") else "vs" - def _format_game_date(self, date_text: str) -> str: - """Render a date as "Sep 19" (default) or "9/19" (``date_format``).""" + def _vs_text(self) -> str: + """Separator drawn between the teams -- "VS", "@", "at", anything.""" + return str(self._scroll_card_option("vs_text", "VS")) + + def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: + """Format an upcoming card's date per scroll_card.date_format.""" raw = str(date_text or "").strip() - if not raw or self._scroll_card_option("date_format", "abbrev") == "numeric": + if not raw: + return "" + fmt = str(self._scroll_card_option("date_format", "abbrev") or "abbrev") + if fmt == "numeric": return raw parts = raw.replace("-", "/").split("/") - if len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): - month = int(parts[0]) - if 1 <= month <= 12: - return f"{self._MONTH_ABBR[month - 1]} {int(parts[1])}" - return raw + if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): + return raw + month, day = int(parts[0]), int(parts[1]) + if not 1 <= month <= 12: + return raw + name = self._MONTH_ABBR[month - 1] + if fmt == "numeric_day_first": + return f"{day}/{month}" + if fmt == "day_first": + return f"{day} {name}" + if fmt == "weekday": + weekday = self._weekday_for(game) + return f"{weekday} {name} {day}" if weekday else f"{name} {day}" + return f"{name} {day}" + + def _weekday_for(self, game: Optional[Dict]) -> str: + """Weekday abbreviation from the game's start time, or ''.""" + if not game: + return "" + raw = game.get("start_time_utc") or game.get("start_time") + if not raw: + return "" + try: + start = raw if isinstance(raw, datetime) else datetime.fromisoformat( + str(raw).replace("Z", "+00:00")) + return self._WEEKDAY_ABBR[start.astimezone(self._card_tzinfo()).weekday()] + except (ValueError, TypeError): + return "" + + def _card_tzinfo(self): + """Timezone for weekday/24h conversions; falls back to UTC.""" + try: + configured = (self.config or {}).get("timezone") + if configured: + return ZoneInfo(configured) + except Exception: + pass + return timezone.utc + + def _format_game_time(self, time_text: str) -> str: + """Return the time as-is (12h) or converted to 24h.""" + raw = str(time_text or "").strip() + if not raw or str(self._scroll_card_option("time_format", "12h")) != "24h": + return raw + cleaned = raw.upper().replace(" ", "") + meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" + if not meridiem: + return raw + try: + hh, _, mm = cleaned[:-2].partition(":") + hour, minute = int(hh), int(mm or 0) + except ValueError: + return raw + if not (0 <= hour <= 12 and 0 <= minute <= 59): + return raw + hour = hour % 12 + (12 if meridiem == "PM" else 0) + return f"{hour:02d}:{minute:02d}" def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: """Draw the middle of an upcoming card. Never a score: an upcoming game has not started, so the extractor's - 0-0 is noise. Either "VS" (default) or the date and time stacked. + 0-0 is noise. Either the VS text (default), the date and time stacked, + or nothing at all. """ - if self._upcoming_center_mode() == "vs": - vs_text = "VS" + mode = self._upcoming_center_mode() + if mode == "none": + return + + if mode == "vs": + vs_text = self._vs_text() + if not vs_text: + return vs_width = draw.textlength(vs_text, font=self.fonts['score']) - vs_x = (self.display_width - vs_width) // 2 - vs_y = (self.display_height // 2) - 3 + vs_x = (self.display_width - vs_width) // 2 + self._layout_offset('score', 'x_offset') + vs_y = (self.display_height // 2) - 3 + self._layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts['score'] + draw, vs_text, (vs_x, vs_y), self.fonts['score'], + fill=self._element_color('score_text') ) return date_text, time_text = self._upcoming_date_and_time(game) - date_text = self._format_game_date(date_text) - font = self.fonts.get('detail') or self.fonts['time'] - lines = [t for t in (date_text, time_text) if t] + lines = [] + if self._scroll_card_option("show_date", True): + lines.append(self._format_game_date(date_text, game)) + if self._scroll_card_option("show_time", True): + lines.append(self._format_game_time(time_text)) + lines = [t for t in lines if t] if not lines: return + font = self.fonts.get('detail') or self.fonts['time'] line_h = 7 top = (self.display_height // 2) - (len(lines) * line_h) // 2 + top += self._layout_offset('score', 'y_offset') for i, line in enumerate(lines): width = draw.textlength(line, font=font) + x = (self.display_width - width) // 2 + self._layout_offset('score', 'x_offset') self._draw_text_with_outline( - draw, line, ((self.display_width - width) // 2, top + i * line_h), font + draw, line, (x, top + i * line_h), font, + fill=self._element_color('detail_text') ) def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: @@ -1149,34 +1281,55 @@ def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: ) def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: - """Draw date/time around an upcoming card: time top, date bottom. + """Draw the date and time around an upcoming card. - Skipped when the date and time are stacked in the middle instead -- - drawing both would print them twice. + Time top and date bottom by default; scroll_card.swap_date_time puts + the date on top instead. Skipped when the pair is stacked in the + middle, which would otherwise print them twice. """ - if self._upcoming_center_mode() != "vs": + if self._upcoming_center_mode() == "date_time": return - date_text, time_text = self._upcoming_date_and_time(game) - date_text = self._format_game_date(date_text) - - if time_text: - time_width = draw.textlength(time_text, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 + date_raw, time_raw = self._upcoming_date_and_time(game) + date_text = (self._format_game_date(date_raw, game) + if self._scroll_card_option("show_date", True) else "") + time_text = (self._format_game_time(time_raw) + if self._scroll_card_option("show_time", True) else "") + + if self._scroll_card_option("swap_date_time", False): + top_text, top_el, bottom_text, bottom_el = ( + date_text, 'date', time_text, 'time') + top_font = self.fonts.get('detail') or self.fonts['time'] + bottom_font = self.fonts['time'] + top_color, bottom_color = 'detail_text', 'period_text' + else: + top_text, top_el, bottom_text, bottom_el = ( + time_text, 'time', date_text, 'date') + top_font = self.fonts['time'] + bottom_font = self.fonts.get('detail') or self.fonts['time'] + top_color, bottom_color = 'period_text', 'detail_text' + + if top_text: + top_width = draw.textlength(top_text, font=top_font) + top_x = (self.display_width - top_width) // 2 + self._layout_offset(top_el, 'x_offset') + top_y = 1 + self._layout_offset(top_el, 'y_offset') self._draw_text_with_outline( - draw, time_text, (time_x, 1), self.fonts['time'] + draw, top_text, (top_x, top_y), top_font, + fill=self._element_color(top_color) ) - if date_text: - date_font = self.fonts.get('detail') or self.fonts['time'] - date_width = draw.textlength(date_text, font=date_font) - date_x = (self.display_width - date_width) // 2 + if bottom_text: + bottom_width = draw.textlength(bottom_text, font=bottom_font) + bottom_x = ((self.display_width - bottom_width) // 2 + + self._layout_offset(bottom_el, 'x_offset')) # Measured, not a fixed -7: the detail font is 6px in most plugins - # but 10px in soccer and nrl, where "Sep 19" ran 5px past the card. - date_bottom = draw.textbbox((0, 0), date_text, font=date_font)[3] - date_y = max(0, self.display_height - date_bottom - 1) + # but 10px in soccer and nrl, where "Sep 19" ran past the card. + ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] + bottom_y = (max(0, self.display_height - ink_bottom - 1) + + self._layout_offset(bottom_el, 'y_offset')) self._draw_text_with_outline( - draw, date_text, (date_x, date_y), date_font + draw, bottom_text, (bottom_x, bottom_y), bottom_font, + fill=self._element_color(bottom_color) ) def _draw_possession_indicator( diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 9259208e..4d5f9a0a 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -27,7 +27,7 @@ { "version": "2.13.0", "released": "2026-08-06", - "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged. The adaptive layout path (layout_mode: adaptive) gets the same treatment: it drew the score unconditionally and used the raw numeric date, so it needed fixing separately from the classic path. Adaptive golden images regenerated for recent and upcoming; the live goldens are pixel-identical and unchanged.", + "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged. The adaptive layout path (layout_mode: adaptive) gets the same treatment: it drew the score unconditionally and used the raw numeric date, so it needed fixing separately from the classic path. Adaptive golden images regenerated for recent and upcoming; the live goldens are pixel-identical and unchanged. Adds a fuller set of scroll_card settings: vs_text (VS, @, at, ...), date_format now covering abbrev/numeric/day_first/numeric_day_first/weekday, time_format 12h or 24h, show_date, show_time, swap_date_time, upcoming_center gains a 'none' option, and center_gap_ratio/min/max for the automatic gap. Each customization text element gains text_color, and the customization.layout X/Y offsets are now honoured by the scroll/Vegas card -- the schema advertised them but only the full-screen scoreboard read them before. The away team is drawn on the left and the home team on the right, so \"at\" and \"@\" read correctly as \"away at home\".", "ledmatrix_min_version": "2.0.0" }, { diff --git a/plugins/hockey-scoreboard/config_schema.json b/plugins/hockey-scoreboard/config_schema.json index f1b17a06..8392ef3b 100644 --- a/plugins/hockey-scoreboard/config_schema.json +++ b/plugins/hockey-scoreboard/config_schema.json @@ -13,29 +13,95 @@ "upcoming_center": { "type": "string", "title": "Middle of an Upcoming Card", - "description": "What to show between the two logos before a game starts: VS, or the date and time stacked in the middle. Upcoming games never show a score, since the game has not been played.", + "description": "What to show between the two logos before a game starts. Upcoming games never show a score, since the game has not been played.", "enum": [ "vs", - "date_time" + "date_time", + "none" ], "default": "vs" }, + "vs_text": { + "type": "string", + "title": "Matchup Separator", + "description": "Text drawn between the two teams, e.g. VS, @, at, v. The away team is always on the left and the home team on the right, so \"at\" and \"@\" read correctly as \"away at home\". Leave blank to draw nothing.", + "default": "VS", + "maxLength": 4 + }, "date_format": { "type": "string", "title": "Date Format", - "description": "How to write the date on an upcoming card: abbrev shows \"Sep 19\", numeric shows \"9/19\".", + "description": "How to write the date: abbrev \"Sep 19\", numeric \"9/19\", day_first \"19 Sep\", numeric_day_first \"19/9\", weekday \"Fri Sep 19\".", "enum": [ "abbrev", - "numeric" + "numeric", + "day_first", + "numeric_day_first", + "weekday" ], "default": "abbrev" }, + "time_format": { + "type": "string", + "title": "Time Format", + "description": "12h shows \"7:00PM\"; 24h shows \"19:00\".", + "enum": [ + "12h", + "24h" + ], + "default": "12h" + }, + "show_date": { + "type": "boolean", + "title": "Show Date", + "description": "Draw the date on upcoming cards.", + "default": true + }, + "show_time": { + "type": "boolean", + "title": "Show Time", + "description": "Draw the start time on upcoming cards.", + "default": true + }, + "swap_date_time": { + "type": "boolean", + "title": "Swap Date and Time", + "description": "Put the date on top and the time along the bottom instead of the default.", + "default": false + }, "center_gap": { "type": "integer", "title": "Center Gap", - "description": "Pixels kept clear down the middle of a card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", + "description": "Pixels kept clear down the middle so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. 0 restores the old edge-to-edge logos.", "minimum": 0, "maximum": 64 + }, + "center_gap_ratio": { + "type": "number", + "title": "Center Gap Ratio", + "description": "Fraction of card width used for the centre gap when it is not pinned.", + "minimum": 0.0, + "maximum": 0.6, + "default": 0.28, + "x-advanced": true + }, + "center_gap_min": { + "type": "integer", + "title": "Center Gap Minimum", + "description": "Lower bound in pixels for the scaled centre gap.", + "minimum": 0, + "maximum": 64, + "default": 22, + "x-advanced": true + }, + "center_gap_max": { + "type": "integer", + "title": "Center Gap Maximum", + "description": "Upper bound in pixels for the scaled centre gap.", + "minimum": 0, + "maximum": 96, + "default": 40, + "x-advanced": true } } }, @@ -128,7 +194,10 @@ "live_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -140,7 +209,10 @@ "recent_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -152,7 +224,10 @@ "upcoming_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } @@ -215,14 +290,18 @@ "properties": { "favorite_teams": { "type": "array", - "items": {"type": "string"}, + "items": { + "type": "string" + }, "default": [], "description": "NHL favorite team abbreviations (e.g., ['TB', 'TOR', 'BOS'])" }, "exclude_teams": { "x-advanced": true, "type": "array", - "items": {"type": "string"}, + "items": { + "type": "string" + }, "default": [], "description": "NHL team abbreviations to always hide from live rotation and recent/final scores (e.g. to avoid spoilers when watching delayed). Takes precedence over favorite_teams and show_all_live." }, @@ -399,11 +478,14 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type for NHL. If not set, uses dynamic calculation (total_games × per_game_duration).", + "description": "Control total duration for each mode type for NHL. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", "properties": { "recent_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -411,7 +493,10 @@ }, "upcoming_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -419,7 +504,10 @@ }, "live_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -535,7 +623,10 @@ "live_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -547,7 +638,10 @@ "recent_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -559,7 +653,10 @@ "upcoming_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } @@ -622,14 +719,18 @@ "properties": { "favorite_teams": { "type": "array", - "items": {"type": "string"}, + "items": { + "type": "string" + }, "default": [], "description": "NCAA Men's Hockey favorite team abbreviations (e.g., ['BU', 'BC', 'MICH'])" }, "exclude_teams": { "x-advanced": true, "type": "array", - "items": {"type": "string"}, + "items": { + "type": "string" + }, "default": [], "description": "NCAA Men's Hockey team abbreviations to always hide from live rotation and recent/final scores (e.g. to avoid spoilers when watching delayed). Takes precedence over favorite_teams and show_all_live." }, @@ -806,11 +907,14 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type for NCAA Men's Hockey. If not set, uses dynamic calculation (total_games × per_game_duration).", + "description": "Control total duration for each mode type for NCAA Men's Hockey. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", "properties": { "recent_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -818,7 +922,10 @@ }, "upcoming_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -826,7 +933,10 @@ }, "live_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -942,7 +1052,10 @@ "live_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -954,7 +1067,10 @@ "recent_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -966,7 +1082,10 @@ "upcoming_display_mode": { "x-advanced": true, "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } @@ -1029,14 +1148,18 @@ "properties": { "favorite_teams": { "type": "array", - "items": {"type": "string"}, + "items": { + "type": "string" + }, "default": [], "description": "NCAA Women's Hockey favorite team abbreviations (e.g., ['WIS', 'MINN', 'OSU']). These are ESPN's codes: Wisconsin is WIS, not WISC." }, "exclude_teams": { "x-advanced": true, "type": "array", - "items": {"type": "string"}, + "items": { + "type": "string" + }, "default": [], "description": "NCAA Women's Hockey team abbreviations to always hide from live rotation and recent/final scores (e.g. to avoid spoilers when watching delayed). Takes precedence over favorite_teams and show_all_live." }, @@ -1213,11 +1336,14 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type for NCAA Women's Hockey. If not set, uses dynamic calculation (total_games × per_game_duration).", + "description": "Control total duration for each mode type for NCAA Women's Hockey. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", "properties": { "recent_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -1225,7 +1351,10 @@ }, "upcoming_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -1233,7 +1362,10 @@ }, "live_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -1357,9 +1489,31 @@ "minimum": 4, "maximum": 16, "default": 10 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the score text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, - "x-propertyOrder": ["font", "font_size"], + "x-propertyOrder": [ + "font", + "font_size" + ], "additionalProperties": false }, "period_text": { @@ -1387,9 +1541,31 @@ "minimum": 4, "maximum": 16, "default": 8 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the period text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, - "x-propertyOrder": ["font", "font_size"], + "x-propertyOrder": [ + "font", + "font_size" + ], "additionalProperties": false }, "team_name": { @@ -1417,9 +1593,31 @@ "minimum": 4, "maximum": 16, "default": 8 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the team name on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, - "x-propertyOrder": ["font", "font_size"], + "x-propertyOrder": [ + "font", + "font_size" + ], "additionalProperties": false }, "status_text": { @@ -1447,9 +1645,31 @@ "minimum": 4, "maximum": 16, "default": 6 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the status text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, - "x-propertyOrder": ["font", "font_size"], + "x-propertyOrder": [ + "font", + "font_size" + ], "additionalProperties": false }, "detail_text": { @@ -1477,9 +1697,31 @@ "minimum": 4, "maximum": 16, "default": 6 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the detail text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, - "x-propertyOrder": ["font", "font_size"], + "x-propertyOrder": [ + "font", + "font_size" + ], "additionalProperties": false }, "rank_text": { @@ -1507,9 +1749,31 @@ "minimum": 4, "maximum": 16, "default": 10 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the rank text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, - "x-propertyOrder": ["font", "font_size"], + "x-propertyOrder": [ + "font", + "font_size" + ], "additionalProperties": false }, "layout": { @@ -1657,7 +1921,15 @@ "additionalProperties": false } }, - "x-propertyOrder": ["home_logo", "away_logo", "score", "status_text", "date", "time", "records"], + "x-propertyOrder": [ + "home_logo", + "away_logo", + "score", + "status_text", + "date", + "time", + "records" + ], "additionalProperties": false }, "favorite_result_colors": { @@ -1683,7 +1955,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [0, 255, 0], + "default": [ + 0, + 255, + 0 + ], "x-advanced": true }, "loss_color": { @@ -1698,7 +1974,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 0, 0], + "default": [ + 255, + 0, + 0 + ], "x-advanced": true }, "tie_color": { @@ -1713,7 +1993,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 200, 0], + "default": [ + 255, + 200, + 0 + ], "x-advanced": true } }, @@ -1726,9 +2010,20 @@ "additionalProperties": false } }, - "x-propertyOrder": ["score_text", "period_text", "team_name", "status_text", "detail_text", "rank_text", "layout", "favorite_result_colors"], + "x-propertyOrder": [ + "score_text", + "period_text", + "team_name", + "status_text", + "detail_text", + "rank_text", + "layout", + "favorite_result_colors" + ], "additionalProperties": false } }, - "required": ["enabled"] + "required": [ + "enabled" + ] } diff --git a/plugins/hockey-scoreboard/game_renderer.py b/plugins/hockey-scoreboard/game_renderer.py index 4c5a2369..0c9d3298 100644 --- a/plugins/hockey-scoreboard/game_renderer.py +++ b/plugins/hockey-scoreboard/game_renderer.py @@ -539,13 +539,17 @@ def render_game_card( center_y = self.display_height // 2 # Draw logos — each centered within its slot on its side. - away_x = (logo_slot - away_logo.width) // 2 - away_y = center_y - (away_logo.height // 2) + away_x = ((logo_slot - away_logo.width) // 2 + + self._layout_offset('away_logo', 'x_offset')) + away_y = (center_y - (away_logo.height // 2) + + self._layout_offset('away_logo', 'y_offset')) main_img.paste(away_logo, (away_x, away_y), away_logo) home_slot_start = self.display_width - logo_slot - home_x = home_slot_start + (logo_slot - home_logo.width) // 2 - home_y = center_y - (home_logo.height // 2) + home_x = (home_slot_start + (logo_slot - home_logo.width) // 2 + + self._layout_offset('home_logo', 'x_offset')) + home_y = (center_y - (home_logo.height // 2) + + self._layout_offset('home_logo', 'y_offset')) main_img.paste(home_logo, (home_x, home_y), home_logo) # Draw scores (centered) - only for live and recent games @@ -554,8 +558,10 @@ def render_game_card( away_score = str(away_team.get("score", "0")) score_text = f"{away_score}-{home_score}" score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 + score_x = ((self.display_width - score_width) // 2 + + self._layout_offset('score', 'x_offset')) + score_y = ((self.display_height // 2) - 3 + + self._layout_offset('score', 'y_offset')) self._draw_text_with_outline( draw_overlay, score_text, (score_x, score_y), self.fonts['score'], fill=self._score_color_for(game, game_type) @@ -672,22 +678,23 @@ def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: return "", "" # ------------------------------------------------------------------ - # Scroll/Vegas card options -- config["scroll_card"]. + # Scroll/Vegas card options -- config["scroll_card"], plus the shared + # customization.layout offsets and per-element colours. # # These only affect the cards this renderer builds, which are used by # scroll_display.py and scroll_display_legacy.py alone. The full-screen # scorebug is drawn elsewhere and is deliberately left untouched. # ------------------------------------------------------------------ - # Middle strip kept clear of logos so the score / "VS" is never drawn on - # top of them. 0.28 of a 128px card clears "1-2" (30px) with room spare. CENTER_GAP_RATIO: ClassVar[float] = 0.28 - # 22 so "VS" (20px) still clears the logos on the narrowest 64px card. CENTER_GAP_MIN_PX: ClassVar[int] = 22 CENTER_GAP_MAX_PX: ClassVar[int] = 40 _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ) + _WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = ( + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", + ) def _scroll_card_option(self, key: str, default: Any = None) -> Any: """Read one key from the scroll_card config block.""" @@ -696,16 +703,58 @@ def _scroll_card_option(self, key: str, default: Any = None) -> Any: return block.get(key) return default + def _layout_offset(self, element: str, axis: str, default: int = 0) -> int: + """X/Y nudge for one element, from customization.layout. + + Same block the full-screen scorebug reads (sports.py + _get_layout_offset), so a nudge configured in the web UI now moves + the element on the scroll/Vegas card too -- previously the schema + advertised these offsets but this renderer ignored them. + """ + try: + layout = (self.config or {}).get("customization", {}).get("layout", {}) + value = (layout.get(element) or {}).get(axis, default) + if isinstance(value, bool): + return default + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + return int(float(value)) + except (TypeError, ValueError): + pass + return default + + def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): + """Per-element text colour from customization..text_color.""" + try: + cfg = (self.config or {}).get("customization", {}).get(element, {}) + value = cfg.get("text_color") + if isinstance(value, (list, tuple)) and len(value) == 3: + return tuple(max(0, min(255, int(c))) for c in value) + if isinstance(value, str) and value.startswith("#") and len(value) == 7: + return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) + except (TypeError, ValueError): + pass + return default + def _center_gap_width(self) -> int: """Width of the middle strip kept clear of logos. - ``scroll_card.center_gap`` overrides it; 0 restores edge-to-edge logos. + ``scroll_card.center_gap`` pins it outright; otherwise it scales with + the card width between the configurable min and max. 0 restores + edge-to-edge logos. """ configured = self._scroll_card_option("center_gap") if isinstance(configured, (int, float)) and configured >= 0: return int(configured) - scaled = round(self.display_width * self.CENTER_GAP_RATIO) - return int(max(self.CENTER_GAP_MIN_PX, min(self.CENTER_GAP_MAX_PX, scaled))) + ratio = self._scroll_card_option("center_gap_ratio", self.CENTER_GAP_RATIO) + low = self._scroll_card_option("center_gap_min", self.CENTER_GAP_MIN_PX) + high = self._scroll_card_option("center_gap_max", self.CENTER_GAP_MAX_PX) + try: + scaled = round(self.display_width * float(ratio)) + return int(max(int(low), min(int(high), scaled))) + except (TypeError, ValueError): + return self.CENTER_GAP_MIN_PX def _logo_slot_width(self) -> int: """Per-side logo slot, leaving the center gap clear. @@ -718,50 +767,124 @@ def _logo_slot_width(self) -> int: return max(8, min(self.display_height, available)) def _upcoming_center_mode(self) -> str: - """What sits in the middle of an upcoming card: 'vs' or 'date_time'.""" + """Middle of an upcoming card: 'vs', 'date_time' or 'none'.""" mode = str(self._scroll_card_option("upcoming_center", "vs") or "vs").lower() - return mode if mode in ("vs", "date_time") else "vs" + return mode if mode in ("vs", "date_time", "none") else "vs" + + def _vs_text(self) -> str: + """Separator drawn between the teams -- "VS", "@", "at", anything.""" + return str(self._scroll_card_option("vs_text", "VS")) - def _format_game_date(self, date_text: str) -> str: - """Render a date as "Sep 19" (default) or "9/19" (``date_format``).""" + def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: + """Format an upcoming card's date per scroll_card.date_format.""" raw = str(date_text or "").strip() - if not raw or self._scroll_card_option("date_format", "abbrev") == "numeric": + if not raw: + return "" + fmt = str(self._scroll_card_option("date_format", "abbrev") or "abbrev") + if fmt == "numeric": return raw parts = raw.replace("-", "/").split("/") - if len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): - month = int(parts[0]) - if 1 <= month <= 12: - return f"{self._MONTH_ABBR[month - 1]} {int(parts[1])}" - return raw + if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): + return raw + month, day = int(parts[0]), int(parts[1]) + if not 1 <= month <= 12: + return raw + name = self._MONTH_ABBR[month - 1] + if fmt == "numeric_day_first": + return f"{day}/{month}" + if fmt == "day_first": + return f"{day} {name}" + if fmt == "weekday": + weekday = self._weekday_for(game) + return f"{weekday} {name} {day}" if weekday else f"{name} {day}" + return f"{name} {day}" + + def _weekday_for(self, game: Optional[Dict]) -> str: + """Weekday abbreviation from the game's start time, or ''.""" + if not game: + return "" + raw = game.get("start_time_utc") or game.get("start_time") + if not raw: + return "" + try: + start = raw if isinstance(raw, datetime) else datetime.fromisoformat( + str(raw).replace("Z", "+00:00")) + return self._WEEKDAY_ABBR[start.astimezone(self._card_tzinfo()).weekday()] + except (ValueError, TypeError): + return "" + + def _card_tzinfo(self): + """Timezone for weekday/24h conversions; falls back to UTC.""" + try: + configured = (self.config or {}).get("timezone") + if configured: + return ZoneInfo(configured) + except Exception: + pass + return timezone.utc + + def _format_game_time(self, time_text: str) -> str: + """Return the time as-is (12h) or converted to 24h.""" + raw = str(time_text or "").strip() + if not raw or str(self._scroll_card_option("time_format", "12h")) != "24h": + return raw + cleaned = raw.upper().replace(" ", "") + meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" + if not meridiem: + return raw + try: + hh, _, mm = cleaned[:-2].partition(":") + hour, minute = int(hh), int(mm or 0) + except ValueError: + return raw + if not (0 <= hour <= 12 and 0 <= minute <= 59): + return raw + hour = hour % 12 + (12 if meridiem == "PM" else 0) + return f"{hour:02d}:{minute:02d}" def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: """Draw the middle of an upcoming card. Never a score: an upcoming game has not started, so the extractor's - 0-0 is noise. Either "VS" (default) or the date and time stacked. + 0-0 is noise. Either the VS text (default), the date and time stacked, + or nothing at all. """ - if self._upcoming_center_mode() == "vs": - vs_text = "VS" + mode = self._upcoming_center_mode() + if mode == "none": + return + + if mode == "vs": + vs_text = self._vs_text() + if not vs_text: + return vs_width = draw.textlength(vs_text, font=self.fonts['score']) - vs_x = (self.display_width - vs_width) // 2 - vs_y = (self.display_height // 2) - 3 + vs_x = (self.display_width - vs_width) // 2 + self._layout_offset('score', 'x_offset') + vs_y = (self.display_height // 2) - 3 + self._layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts['score'] + draw, vs_text, (vs_x, vs_y), self.fonts['score'], + fill=self._element_color('score_text') ) return date_text, time_text = self._upcoming_date_and_time(game) - date_text = self._format_game_date(date_text) - font = self.fonts.get('detail') or self.fonts['time'] - lines = [t for t in (date_text, time_text) if t] + lines = [] + if self._scroll_card_option("show_date", True): + lines.append(self._format_game_date(date_text, game)) + if self._scroll_card_option("show_time", True): + lines.append(self._format_game_time(time_text)) + lines = [t for t in lines if t] if not lines: return + font = self.fonts.get('detail') or self.fonts['time'] line_h = 7 top = (self.display_height // 2) - (len(lines) * line_h) // 2 + top += self._layout_offset('score', 'y_offset') for i, line in enumerate(lines): width = draw.textlength(line, font=font) + x = (self.display_width - width) // 2 + self._layout_offset('score', 'x_offset') self._draw_text_with_outline( - draw, line, ((self.display_width - width) // 2, top + i * line_h), font + draw, line, (x, top + i * line_h), font, + fill=self._element_color('detail_text') ) @staticmethod @@ -788,34 +911,55 @@ def _display_tzinfo(self): return timezone.utc def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: - """Draw date/time around an upcoming card: time top, date bottom. + """Draw the date and time around an upcoming card. - Skipped when the date and time are stacked in the middle instead -- - drawing both would print them twice. + Time top and date bottom by default; scroll_card.swap_date_time puts + the date on top instead. Skipped when the pair is stacked in the + middle, which would otherwise print them twice. """ - if self._upcoming_center_mode() != "vs": + if self._upcoming_center_mode() == "date_time": return - date_text, time_text = self._upcoming_date_and_time(game) - date_text = self._format_game_date(date_text) - - if time_text: - time_width = draw.textlength(time_text, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 + date_raw, time_raw = self._upcoming_date_and_time(game) + date_text = (self._format_game_date(date_raw, game) + if self._scroll_card_option("show_date", True) else "") + time_text = (self._format_game_time(time_raw) + if self._scroll_card_option("show_time", True) else "") + + if self._scroll_card_option("swap_date_time", False): + top_text, top_el, bottom_text, bottom_el = ( + date_text, 'date', time_text, 'time') + top_font = self.fonts.get('detail') or self.fonts['time'] + bottom_font = self.fonts['time'] + top_color, bottom_color = 'detail_text', 'period_text' + else: + top_text, top_el, bottom_text, bottom_el = ( + time_text, 'time', date_text, 'date') + top_font = self.fonts['time'] + bottom_font = self.fonts.get('detail') or self.fonts['time'] + top_color, bottom_color = 'period_text', 'detail_text' + + if top_text: + top_width = draw.textlength(top_text, font=top_font) + top_x = (self.display_width - top_width) // 2 + self._layout_offset(top_el, 'x_offset') + top_y = 1 + self._layout_offset(top_el, 'y_offset') self._draw_text_with_outline( - draw, time_text, (time_x, 1), self.fonts['time'] + draw, top_text, (top_x, top_y), top_font, + fill=self._element_color(top_color) ) - if date_text: - date_font = self.fonts.get('detail') or self.fonts['time'] - date_width = draw.textlength(date_text, font=date_font) - date_x = (self.display_width - date_width) // 2 + if bottom_text: + bottom_width = draw.textlength(bottom_text, font=bottom_font) + bottom_x = ((self.display_width - bottom_width) // 2 + + self._layout_offset(bottom_el, 'x_offset')) # Measured, not a fixed -7: the detail font is 6px in most plugins - # but 10px in soccer and nrl, where "Sep 19" ran 5px past the card. - date_bottom = draw.textbbox((0, 0), date_text, font=date_font)[3] - date_y = max(0, self.display_height - date_bottom - 1) + # but 10px in soccer and nrl, where "Sep 19" ran past the card. + ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] + bottom_y = (max(0, self.display_height - ink_bottom - 1) + + self._layout_offset(bottom_el, 'y_offset')) self._draw_text_with_outline( - draw, date_text, (date_x, date_y), date_font + draw, bottom_text, (bottom_x, bottom_y), bottom_font, + fill=self._element_color(bottom_color) ) def _draw_records_or_rankings(self, draw: ImageDraw.Draw, game: Dict) -> None: diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index e85cc3bb..2b25c7d7 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -57,7 +57,7 @@ { "version": "1.9.0", "released": "2026-08-06", - "notes": "Fix missing text and cramped spacing on scroll and Vegas game cards. Upcoming games showed a bare \"VS\" with no date or time: the card read status.short_detail and start_time, but the scroll path is fed by the sports extractor, which emits game_date, game_time and start_time_utc instead, so both lookups came up empty. Live games showed the period without the game clock (\"P2\" rather than \"P2 12:34\") because the payload normalizer wrote status.clock while the card reads the canonical status.display_clock. Date and time now render top- and bottom-center to match the other sports, and the live clock renders again. Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged.", + "notes": "Fix missing text and cramped spacing on scroll and Vegas game cards. Upcoming games showed a bare \"VS\" with no date or time: the card read status.short_detail and start_time, but the scroll path is fed by the sports extractor, which emits game_date, game_time and start_time_utc instead, so both lookups came up empty. Live games showed the period without the game clock (\"P2\" rather than \"P2 12:34\") because the payload normalizer wrote status.clock while the card reads the canonical status.display_clock. Date and time now render top- and bottom-center to match the other sports, and the live clock renders again. Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged. Adds a fuller set of scroll_card settings: vs_text (VS, @, at, ...), date_format now covering abbrev/numeric/day_first/numeric_day_first/weekday, time_format 12h or 24h, show_date, show_time, swap_date_time, upcoming_center gains a 'none' option, and center_gap_ratio/min/max for the automatic gap. Each customization text element gains text_color, and the customization.layout X/Y offsets are now honoured by the scroll/Vegas card -- the schema advertised them but only the full-screen scoreboard read them before. The away team is drawn on the left and the home team on the right, so \"at\" and \"@\" read correctly as \"away at home\".", "ledmatrix_min_version": "2.0.0" }, { diff --git a/plugins/lacrosse-scoreboard/config_schema.json b/plugins/lacrosse-scoreboard/config_schema.json index f1e30850..5de602cb 100644 --- a/plugins/lacrosse-scoreboard/config_schema.json +++ b/plugins/lacrosse-scoreboard/config_schema.json @@ -13,29 +13,95 @@ "upcoming_center": { "type": "string", "title": "Middle of an Upcoming Card", - "description": "What to show between the two logos before a game starts: VS, or the date and time stacked in the middle. Upcoming games never show a score, since the game has not been played.", + "description": "What to show between the two logos before a game starts. Upcoming games never show a score, since the game has not been played.", "enum": [ "vs", - "date_time" + "date_time", + "none" ], "default": "vs" }, + "vs_text": { + "type": "string", + "title": "Matchup Separator", + "description": "Text drawn between the two teams, e.g. VS, @, at, v. The away team is always on the left and the home team on the right, so \"at\" and \"@\" read correctly as \"away at home\". Leave blank to draw nothing.", + "default": "VS", + "maxLength": 4 + }, "date_format": { "type": "string", "title": "Date Format", - "description": "How to write the date on an upcoming card: abbrev shows \"Sep 19\", numeric shows \"9/19\".", + "description": "How to write the date: abbrev \"Sep 19\", numeric \"9/19\", day_first \"19 Sep\", numeric_day_first \"19/9\", weekday \"Fri Sep 19\".", "enum": [ "abbrev", - "numeric" + "numeric", + "day_first", + "numeric_day_first", + "weekday" ], "default": "abbrev" }, + "time_format": { + "type": "string", + "title": "Time Format", + "description": "12h shows \"7:00PM\"; 24h shows \"19:00\".", + "enum": [ + "12h", + "24h" + ], + "default": "12h" + }, + "show_date": { + "type": "boolean", + "title": "Show Date", + "description": "Draw the date on upcoming cards.", + "default": true + }, + "show_time": { + "type": "boolean", + "title": "Show Time", + "description": "Draw the start time on upcoming cards.", + "default": true + }, + "swap_date_time": { + "type": "boolean", + "title": "Swap Date and Time", + "description": "Put the date on top and the time along the bottom instead of the default.", + "default": false + }, "center_gap": { "type": "integer", "title": "Center Gap", - "description": "Pixels kept clear down the middle of a card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", + "description": "Pixels kept clear down the middle so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. 0 restores the old edge-to-edge logos.", "minimum": 0, "maximum": 64 + }, + "center_gap_ratio": { + "type": "number", + "title": "Center Gap Ratio", + "description": "Fraction of card width used for the centre gap when it is not pinned.", + "minimum": 0.0, + "maximum": 0.6, + "default": 0.28, + "x-advanced": true + }, + "center_gap_min": { + "type": "integer", + "title": "Center Gap Minimum", + "description": "Lower bound in pixels for the scaled centre gap.", + "minimum": 0, + "maximum": 64, + "default": 22, + "x-advanced": true + }, + "center_gap_max": { + "type": "integer", + "title": "Center Gap Maximum", + "description": "Upper bound in pixels for the scaled centre gap.", + "minimum": 0, + "maximum": 96, + "default": 40, + "x-advanced": true } } }, @@ -966,6 +1032,25 @@ "minimum": 4, "maximum": 16, "default": 10 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the score text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -999,6 +1084,25 @@ "minimum": 4, "maximum": 16, "default": 8 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the period text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -1032,6 +1136,25 @@ "minimum": 4, "maximum": 16, "default": 8 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the team name on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -1065,6 +1188,25 @@ "minimum": 4, "maximum": 16, "default": 6 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the status text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -1098,6 +1240,25 @@ "minimum": 4, "maximum": 16, "default": 6 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the detail text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -1131,6 +1292,25 @@ "minimum": 4, "maximum": 16, "default": 10 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the rank text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -1318,7 +1498,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [0, 255, 0], + "default": [ + 0, + 255, + 0 + ], "x-advanced": true }, "loss_color": { @@ -1333,7 +1517,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 0, 0], + "default": [ + 255, + 0, + 0 + ], "x-advanced": true }, "tie_color": { @@ -1348,7 +1536,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 200, 0], + "default": [ + 255, + 200, + 0 + ], "x-advanced": true } }, diff --git a/plugins/lacrosse-scoreboard/game_renderer.py b/plugins/lacrosse-scoreboard/game_renderer.py index 507d94e9..09276860 100644 --- a/plugins/lacrosse-scoreboard/game_renderer.py +++ b/plugins/lacrosse-scoreboard/game_renderer.py @@ -516,13 +516,17 @@ def render_game_card( # Draw logos — each centered within a slot on its side; cap at half the card # width so home_slot_start stays non-negative on square/tall displays logo_slot = self._logo_slot_width() - away_x = (logo_slot - away_logo.width) // 2 - away_y = center_y - (away_logo.height // 2) + away_x = ((logo_slot - away_logo.width) // 2 + + self._layout_offset('away_logo', 'x_offset')) + away_y = (center_y - (away_logo.height // 2) + + self._layout_offset('away_logo', 'y_offset')) main_img.paste(away_logo, (away_x, away_y), away_logo) home_slot_start = self.display_width - logo_slot - home_x = home_slot_start + (logo_slot - home_logo.width) // 2 - home_y = center_y - (home_logo.height // 2) + home_x = (home_slot_start + (logo_slot - home_logo.width) // 2 + + self._layout_offset('home_logo', 'x_offset')) + home_y = (center_y - (home_logo.height // 2) + + self._layout_offset('home_logo', 'y_offset')) main_img.paste(home_logo, (home_x, home_y), home_logo) # Draw scores (centered) - only for live and recent games @@ -531,8 +535,10 @@ def render_game_card( away_score = str(away_team.get("score", "0")) score_text = f"{away_score}-{home_score}" score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 + score_x = ((self.display_width - score_width) // 2 + + self._layout_offset('score', 'x_offset')) + score_y = ((self.display_height // 2) - 3 + + self._layout_offset('score', 'y_offset')) self._draw_text_with_outline( draw_overlay, score_text, (score_x, score_y), self.fonts['score'], fill=self._score_color_for(game, game_type) @@ -620,22 +626,23 @@ def _draw_recent_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: self._draw_text_with_outline(draw, status_text, (status_x, status_y), self.fonts['time']) # ------------------------------------------------------------------ - # Scroll/Vegas card options -- config["scroll_card"]. + # Scroll/Vegas card options -- config["scroll_card"], plus the shared + # customization.layout offsets and per-element colours. # # These only affect the cards this renderer builds, which are used by # scroll_display.py and scroll_display_legacy.py alone. The full-screen # scorebug is drawn elsewhere and is deliberately left untouched. # ------------------------------------------------------------------ - # Middle strip kept clear of logos so the score / "VS" is never drawn on - # top of them. 0.28 of a 128px card clears "1-2" (30px) with room spare. CENTER_GAP_RATIO: ClassVar[float] = 0.28 - # 22 so "VS" (20px) still clears the logos on the narrowest 64px card. CENTER_GAP_MIN_PX: ClassVar[int] = 22 CENTER_GAP_MAX_PX: ClassVar[int] = 40 _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ) + _WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = ( + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", + ) def _logo_cache_key(self, name: str) -> str: """Cache key scoped to the logo slot. @@ -652,16 +659,58 @@ def _scroll_card_option(self, key: str, default: Any = None) -> Any: return block.get(key) return default + def _layout_offset(self, element: str, axis: str, default: int = 0) -> int: + """X/Y nudge for one element, from customization.layout. + + Same block the full-screen scorebug reads (sports.py + _get_layout_offset), so a nudge configured in the web UI now moves + the element on the scroll/Vegas card too -- previously the schema + advertised these offsets but this renderer ignored them. + """ + try: + layout = (self.config or {}).get("customization", {}).get("layout", {}) + value = (layout.get(element) or {}).get(axis, default) + if isinstance(value, bool): + return default + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + return int(float(value)) + except (TypeError, ValueError): + pass + return default + + def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): + """Per-element text colour from customization..text_color.""" + try: + cfg = (self.config or {}).get("customization", {}).get(element, {}) + value = cfg.get("text_color") + if isinstance(value, (list, tuple)) and len(value) == 3: + return tuple(max(0, min(255, int(c))) for c in value) + if isinstance(value, str) and value.startswith("#") and len(value) == 7: + return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) + except (TypeError, ValueError): + pass + return default + def _center_gap_width(self) -> int: """Width of the middle strip kept clear of logos. - ``scroll_card.center_gap`` overrides it; 0 restores edge-to-edge logos. + ``scroll_card.center_gap`` pins it outright; otherwise it scales with + the card width between the configurable min and max. 0 restores + edge-to-edge logos. """ configured = self._scroll_card_option("center_gap") if isinstance(configured, (int, float)) and configured >= 0: return int(configured) - scaled = round(self.display_width * self.CENTER_GAP_RATIO) - return int(max(self.CENTER_GAP_MIN_PX, min(self.CENTER_GAP_MAX_PX, scaled))) + ratio = self._scroll_card_option("center_gap_ratio", self.CENTER_GAP_RATIO) + low = self._scroll_card_option("center_gap_min", self.CENTER_GAP_MIN_PX) + high = self._scroll_card_option("center_gap_max", self.CENTER_GAP_MAX_PX) + try: + scaled = round(self.display_width * float(ratio)) + return int(max(int(low), min(int(high), scaled))) + except (TypeError, ValueError): + return self.CENTER_GAP_MIN_PX def _logo_slot_width(self) -> int: """Per-side logo slot, leaving the center gap clear. @@ -674,50 +723,124 @@ def _logo_slot_width(self) -> int: return max(8, min(self.display_height, available)) def _upcoming_center_mode(self) -> str: - """What sits in the middle of an upcoming card: 'vs' or 'date_time'.""" + """Middle of an upcoming card: 'vs', 'date_time' or 'none'.""" mode = str(self._scroll_card_option("upcoming_center", "vs") or "vs").lower() - return mode if mode in ("vs", "date_time") else "vs" + return mode if mode in ("vs", "date_time", "none") else "vs" + + def _vs_text(self) -> str: + """Separator drawn between the teams -- "VS", "@", "at", anything.""" + return str(self._scroll_card_option("vs_text", "VS")) - def _format_game_date(self, date_text: str) -> str: - """Render a date as "Sep 19" (default) or "9/19" (``date_format``).""" + def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: + """Format an upcoming card's date per scroll_card.date_format.""" raw = str(date_text or "").strip() - if not raw or self._scroll_card_option("date_format", "abbrev") == "numeric": + if not raw: + return "" + fmt = str(self._scroll_card_option("date_format", "abbrev") or "abbrev") + if fmt == "numeric": return raw parts = raw.replace("-", "/").split("/") - if len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): - month = int(parts[0]) - if 1 <= month <= 12: - return f"{self._MONTH_ABBR[month - 1]} {int(parts[1])}" - return raw + if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): + return raw + month, day = int(parts[0]), int(parts[1]) + if not 1 <= month <= 12: + return raw + name = self._MONTH_ABBR[month - 1] + if fmt == "numeric_day_first": + return f"{day}/{month}" + if fmt == "day_first": + return f"{day} {name}" + if fmt == "weekday": + weekday = self._weekday_for(game) + return f"{weekday} {name} {day}" if weekday else f"{name} {day}" + return f"{name} {day}" + + def _weekday_for(self, game: Optional[Dict]) -> str: + """Weekday abbreviation from the game's start time, or ''.""" + if not game: + return "" + raw = game.get("start_time_utc") or game.get("start_time") + if not raw: + return "" + try: + start = raw if isinstance(raw, datetime) else datetime.fromisoformat( + str(raw).replace("Z", "+00:00")) + return self._WEEKDAY_ABBR[start.astimezone(self._card_tzinfo()).weekday()] + except (ValueError, TypeError): + return "" + + def _card_tzinfo(self): + """Timezone for weekday/24h conversions; falls back to UTC.""" + try: + configured = (self.config or {}).get("timezone") + if configured: + return ZoneInfo(configured) + except Exception: + pass + return timezone.utc + + def _format_game_time(self, time_text: str) -> str: + """Return the time as-is (12h) or converted to 24h.""" + raw = str(time_text or "").strip() + if not raw or str(self._scroll_card_option("time_format", "12h")) != "24h": + return raw + cleaned = raw.upper().replace(" ", "") + meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" + if not meridiem: + return raw + try: + hh, _, mm = cleaned[:-2].partition(":") + hour, minute = int(hh), int(mm or 0) + except ValueError: + return raw + if not (0 <= hour <= 12 and 0 <= minute <= 59): + return raw + hour = hour % 12 + (12 if meridiem == "PM" else 0) + return f"{hour:02d}:{minute:02d}" def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: """Draw the middle of an upcoming card. Never a score: an upcoming game has not started, so the extractor's - 0-0 is noise. Either "VS" (default) or the date and time stacked. + 0-0 is noise. Either the VS text (default), the date and time stacked, + or nothing at all. """ - if self._upcoming_center_mode() == "vs": - vs_text = "VS" + mode = self._upcoming_center_mode() + if mode == "none": + return + + if mode == "vs": + vs_text = self._vs_text() + if not vs_text: + return vs_width = draw.textlength(vs_text, font=self.fonts['score']) - vs_x = (self.display_width - vs_width) // 2 - vs_y = (self.display_height // 2) - 3 + vs_x = (self.display_width - vs_width) // 2 + self._layout_offset('score', 'x_offset') + vs_y = (self.display_height // 2) - 3 + self._layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts['score'] + draw, vs_text, (vs_x, vs_y), self.fonts['score'], + fill=self._element_color('score_text') ) return date_text, time_text = self._upcoming_date_and_time(game) - date_text = self._format_game_date(date_text) - font = self.fonts.get('detail') or self.fonts['time'] - lines = [t for t in (date_text, time_text) if t] + lines = [] + if self._scroll_card_option("show_date", True): + lines.append(self._format_game_date(date_text, game)) + if self._scroll_card_option("show_time", True): + lines.append(self._format_game_time(time_text)) + lines = [t for t in lines if t] if not lines: return + font = self.fonts.get('detail') or self.fonts['time'] line_h = 7 top = (self.display_height // 2) - (len(lines) * line_h) // 2 + top += self._layout_offset('score', 'y_offset') for i, line in enumerate(lines): width = draw.textlength(line, font=font) + x = (self.display_width - width) // 2 + self._layout_offset('score', 'x_offset') self._draw_text_with_outline( - draw, line, ((self.display_width - width) // 2, top + i * line_h), font + draw, line, (x, top + i * line_h), font, + fill=self._element_color('detail_text') ) def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: @@ -728,34 +851,55 @@ def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: ) def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: - """Draw date/time around an upcoming card: time top, date bottom. + """Draw the date and time around an upcoming card. - Skipped when the date and time are stacked in the middle instead -- - drawing both would print them twice. + Time top and date bottom by default; scroll_card.swap_date_time puts + the date on top instead. Skipped when the pair is stacked in the + middle, which would otherwise print them twice. """ - if self._upcoming_center_mode() != "vs": + if self._upcoming_center_mode() == "date_time": return - date_text, time_text = self._upcoming_date_and_time(game) - date_text = self._format_game_date(date_text) - - if time_text: - time_width = draw.textlength(time_text, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 + date_raw, time_raw = self._upcoming_date_and_time(game) + date_text = (self._format_game_date(date_raw, game) + if self._scroll_card_option("show_date", True) else "") + time_text = (self._format_game_time(time_raw) + if self._scroll_card_option("show_time", True) else "") + + if self._scroll_card_option("swap_date_time", False): + top_text, top_el, bottom_text, bottom_el = ( + date_text, 'date', time_text, 'time') + top_font = self.fonts.get('detail') or self.fonts['time'] + bottom_font = self.fonts['time'] + top_color, bottom_color = 'detail_text', 'period_text' + else: + top_text, top_el, bottom_text, bottom_el = ( + time_text, 'time', date_text, 'date') + top_font = self.fonts['time'] + bottom_font = self.fonts.get('detail') or self.fonts['time'] + top_color, bottom_color = 'period_text', 'detail_text' + + if top_text: + top_width = draw.textlength(top_text, font=top_font) + top_x = (self.display_width - top_width) // 2 + self._layout_offset(top_el, 'x_offset') + top_y = 1 + self._layout_offset(top_el, 'y_offset') self._draw_text_with_outline( - draw, time_text, (time_x, 1), self.fonts['time'] + draw, top_text, (top_x, top_y), top_font, + fill=self._element_color(top_color) ) - if date_text: - date_font = self.fonts.get('detail') or self.fonts['time'] - date_width = draw.textlength(date_text, font=date_font) - date_x = (self.display_width - date_width) // 2 + if bottom_text: + bottom_width = draw.textlength(bottom_text, font=bottom_font) + bottom_x = ((self.display_width - bottom_width) // 2 + + self._layout_offset(bottom_el, 'x_offset')) # Measured, not a fixed -7: the detail font is 6px in most plugins - # but 10px in soccer and nrl, where "Sep 19" ran 5px past the card. - date_bottom = draw.textbbox((0, 0), date_text, font=date_font)[3] - date_y = max(0, self.display_height - date_bottom - 1) + # but 10px in soccer and nrl, where "Sep 19" ran past the card. + ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] + bottom_y = (max(0, self.display_height - ink_bottom - 1) + + self._layout_offset(bottom_el, 'y_offset')) self._draw_text_with_outline( - draw, date_text, (date_x, date_y), date_font + draw, bottom_text, (bottom_x, bottom_y), bottom_font, + fill=self._element_color(bottom_color) ) def _draw_dynamic_odds( diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index 06899c64..5ee7d8f9 100644 --- a/plugins/lacrosse-scoreboard/manifest.json +++ b/plugins/lacrosse-scoreboard/manifest.json @@ -53,7 +53,7 @@ { "version": "1.9.0", "released": "2026-08-06", - "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged.", + "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged. Adds a fuller set of scroll_card settings: vs_text (VS, @, at, ...), date_format now covering abbrev/numeric/day_first/numeric_day_first/weekday, time_format 12h or 24h, show_date, show_time, swap_date_time, upcoming_center gains a 'none' option, and center_gap_ratio/min/max for the automatic gap. Each customization text element gains text_color, and the customization.layout X/Y offsets are now honoured by the scroll/Vegas card -- the schema advertised them but only the full-screen scoreboard read them before. The away team is drawn on the left and the home team on the right, so \"at\" and \"@\" read correctly as \"away at home\".", "ledmatrix_min_version": "2.0.0" }, { diff --git a/plugins/nrl-scoreboard/config_schema.json b/plugins/nrl-scoreboard/config_schema.json index 9f2d7f99..8b2bff5f 100644 --- a/plugins/nrl-scoreboard/config_schema.json +++ b/plugins/nrl-scoreboard/config_schema.json @@ -13,29 +13,95 @@ "upcoming_center": { "type": "string", "title": "Middle of an Upcoming Card", - "description": "What to show between the two logos before a game starts: VS, or the date and time stacked in the middle. Upcoming games never show a score, since the game has not been played.", + "description": "What to show between the two logos before a game starts. Upcoming games never show a score, since the game has not been played.", "enum": [ "vs", - "date_time" + "date_time", + "none" ], "default": "vs" }, + "vs_text": { + "type": "string", + "title": "Matchup Separator", + "description": "Text drawn between the two teams, e.g. VS, @, at, v. The away team is always on the left and the home team on the right, so \"at\" and \"@\" read correctly as \"away at home\". Leave blank to draw nothing.", + "default": "VS", + "maxLength": 4 + }, "date_format": { "type": "string", "title": "Date Format", - "description": "How to write the date on an upcoming card: abbrev shows \"Sep 19\", numeric shows \"9/19\".", + "description": "How to write the date: abbrev \"Sep 19\", numeric \"9/19\", day_first \"19 Sep\", numeric_day_first \"19/9\", weekday \"Fri Sep 19\".", "enum": [ "abbrev", - "numeric" + "numeric", + "day_first", + "numeric_day_first", + "weekday" ], "default": "abbrev" }, + "time_format": { + "type": "string", + "title": "Time Format", + "description": "12h shows \"7:00PM\"; 24h shows \"19:00\".", + "enum": [ + "12h", + "24h" + ], + "default": "12h" + }, + "show_date": { + "type": "boolean", + "title": "Show Date", + "description": "Draw the date on upcoming cards.", + "default": true + }, + "show_time": { + "type": "boolean", + "title": "Show Time", + "description": "Draw the start time on upcoming cards.", + "default": true + }, + "swap_date_time": { + "type": "boolean", + "title": "Swap Date and Time", + "description": "Put the date on top and the time along the bottom instead of the default.", + "default": false + }, "center_gap": { "type": "integer", "title": "Center Gap", - "description": "Pixels kept clear down the middle of a card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", + "description": "Pixels kept clear down the middle so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. 0 restores the old edge-to-edge logos.", "minimum": 0, "maximum": 64 + }, + "center_gap_ratio": { + "type": "number", + "title": "Center Gap Ratio", + "description": "Fraction of card width used for the centre gap when it is not pinned.", + "minimum": 0.0, + "maximum": 0.6, + "default": 0.28, + "x-advanced": true + }, + "center_gap_min": { + "type": "integer", + "title": "Center Gap Minimum", + "description": "Lower bound in pixels for the scaled centre gap.", + "minimum": 0, + "maximum": 64, + "default": 22, + "x-advanced": true + }, + "center_gap_max": { + "type": "integer", + "title": "Center Gap Maximum", + "description": "Upper bound in pixels for the scaled centre gap.", + "minimum": 0, + "maximum": 96, + "default": 40, + "x-advanced": true } } }, @@ -586,6 +652,25 @@ "minimum": 4, "maximum": 16, "default": 10 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the score text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -620,6 +705,25 @@ "minimum": 4, "maximum": 16, "default": 8 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the period text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -654,6 +758,25 @@ "minimum": 4, "maximum": 16, "default": 8 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the team name on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -688,6 +811,25 @@ "minimum": 4, "maximum": 16, "default": 6 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the status text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -722,6 +864,25 @@ "minimum": 4, "maximum": 16, "default": 6 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the detail text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -756,6 +917,25 @@ "minimum": 4, "maximum": 16, "default": 10 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the rank text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -928,7 +1108,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [0, 255, 0], + "default": [ + 0, + 255, + 0 + ], "x-advanced": true }, "loss_color": { @@ -943,7 +1127,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 0, 0], + "default": [ + 255, + 0, + 0 + ], "x-advanced": true }, "tie_color": { @@ -958,7 +1146,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 200, 0], + "default": [ + 255, + 200, + 0 + ], "x-advanced": true } }, @@ -983,7 +1175,7 @@ ], "additionalProperties": false } -}, + }, "additionalProperties": false, "required": [ "enabled" @@ -1023,4 +1215,4 @@ "background_service", "customization" ] -} \ No newline at end of file +} diff --git a/plugins/nrl-scoreboard/game_renderer.py b/plugins/nrl-scoreboard/game_renderer.py index b68d8f4c..146dab2c 100644 --- a/plugins/nrl-scoreboard/game_renderer.py +++ b/plugins/nrl-scoreboard/game_renderer.py @@ -471,12 +471,16 @@ def render_game_card( # Place logos — each centered within a slot on its side; cap at half the card # width so home_slot_start stays non-negative on square/tall displays logo_slot = self._logo_slot_width() - away_x = (logo_slot - away_logo.width) // 2 - away_y = center_y - (away_logo.height // 2) + away_x = ((logo_slot - away_logo.width) // 2 + + self._layout_offset('away_logo', 'x_offset')) + away_y = (center_y - (away_logo.height // 2) + + self._layout_offset('away_logo', 'y_offset')) home_slot_start = self.display_width - logo_slot - home_x = home_slot_start + (logo_slot - home_logo.width) // 2 - home_y = center_y - (home_logo.height // 2) + home_x = (home_slot_start + (logo_slot - home_logo.width) // 2 + + self._layout_offset('home_logo', 'x_offset')) + home_y = (center_y - (home_logo.height // 2) + + self._layout_offset('home_logo', 'y_offset')) # Draw logos main_img.paste(home_logo, (home_x, home_y), home_logo) @@ -485,8 +489,10 @@ def render_game_card( # Draw scores (centered) — only once a game has started. Upcoming games # have no score, so the extractor's 0-0 was pure noise. if game_type in ("live", "recent"): - score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 + score_x = ((self.display_width - score_width) // 2 + + self._layout_offset('score', 'x_offset')) + score_y = ((self.display_height // 2) - 3 + + self._layout_offset('score', 'y_offset')) self._draw_text_with_outline( draw_overlay, score_text, (score_x, score_y), self.fonts['score'], fill=self._score_color_for(game, game_type) @@ -561,22 +567,23 @@ def _draw_recent_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['detail']) # ------------------------------------------------------------------ - # Scroll/Vegas card options -- config["scroll_card"]. + # Scroll/Vegas card options -- config["scroll_card"], plus the shared + # customization.layout offsets and per-element colours. # # These only affect the cards this renderer builds, which are used by # scroll_display.py and scroll_display_legacy.py alone. The full-screen # scorebug is drawn elsewhere and is deliberately left untouched. # ------------------------------------------------------------------ - # Middle strip kept clear of logos so the score / "VS" is never drawn on - # top of them. 0.28 of a 128px card clears "1-2" (30px) with room spare. CENTER_GAP_RATIO: ClassVar[float] = 0.28 - # 22 so "VS" (20px) still clears the logos on the narrowest 64px card. CENTER_GAP_MIN_PX: ClassVar[int] = 22 CENTER_GAP_MAX_PX: ClassVar[int] = 40 _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ) + _WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = ( + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", + ) def _logo_cache_key(self, name: str) -> str: """Cache key scoped to the logo slot. @@ -593,16 +600,58 @@ def _scroll_card_option(self, key: str, default: Any = None) -> Any: return block.get(key) return default + def _layout_offset(self, element: str, axis: str, default: int = 0) -> int: + """X/Y nudge for one element, from customization.layout. + + Same block the full-screen scorebug reads (sports.py + _get_layout_offset), so a nudge configured in the web UI now moves + the element on the scroll/Vegas card too -- previously the schema + advertised these offsets but this renderer ignored them. + """ + try: + layout = (self.config or {}).get("customization", {}).get("layout", {}) + value = (layout.get(element) or {}).get(axis, default) + if isinstance(value, bool): + return default + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + return int(float(value)) + except (TypeError, ValueError): + pass + return default + + def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): + """Per-element text colour from customization..text_color.""" + try: + cfg = (self.config or {}).get("customization", {}).get(element, {}) + value = cfg.get("text_color") + if isinstance(value, (list, tuple)) and len(value) == 3: + return tuple(max(0, min(255, int(c))) for c in value) + if isinstance(value, str) and value.startswith("#") and len(value) == 7: + return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) + except (TypeError, ValueError): + pass + return default + def _center_gap_width(self) -> int: """Width of the middle strip kept clear of logos. - ``scroll_card.center_gap`` overrides it; 0 restores edge-to-edge logos. + ``scroll_card.center_gap`` pins it outright; otherwise it scales with + the card width between the configurable min and max. 0 restores + edge-to-edge logos. """ configured = self._scroll_card_option("center_gap") if isinstance(configured, (int, float)) and configured >= 0: return int(configured) - scaled = round(self.display_width * self.CENTER_GAP_RATIO) - return int(max(self.CENTER_GAP_MIN_PX, min(self.CENTER_GAP_MAX_PX, scaled))) + ratio = self._scroll_card_option("center_gap_ratio", self.CENTER_GAP_RATIO) + low = self._scroll_card_option("center_gap_min", self.CENTER_GAP_MIN_PX) + high = self._scroll_card_option("center_gap_max", self.CENTER_GAP_MAX_PX) + try: + scaled = round(self.display_width * float(ratio)) + return int(max(int(low), min(int(high), scaled))) + except (TypeError, ValueError): + return self.CENTER_GAP_MIN_PX def _logo_slot_width(self) -> int: """Per-side logo slot, leaving the center gap clear. @@ -615,50 +664,124 @@ def _logo_slot_width(self) -> int: return max(8, min(self.display_height, available)) def _upcoming_center_mode(self) -> str: - """What sits in the middle of an upcoming card: 'vs' or 'date_time'.""" + """Middle of an upcoming card: 'vs', 'date_time' or 'none'.""" mode = str(self._scroll_card_option("upcoming_center", "vs") or "vs").lower() - return mode if mode in ("vs", "date_time") else "vs" + return mode if mode in ("vs", "date_time", "none") else "vs" - def _format_game_date(self, date_text: str) -> str: - """Render a date as "Sep 19" (default) or "9/19" (``date_format``).""" + def _vs_text(self) -> str: + """Separator drawn between the teams -- "VS", "@", "at", anything.""" + return str(self._scroll_card_option("vs_text", "VS")) + + def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: + """Format an upcoming card's date per scroll_card.date_format.""" raw = str(date_text or "").strip() - if not raw or self._scroll_card_option("date_format", "abbrev") == "numeric": + if not raw: + return "" + fmt = str(self._scroll_card_option("date_format", "abbrev") or "abbrev") + if fmt == "numeric": return raw parts = raw.replace("-", "/").split("/") - if len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): - month = int(parts[0]) - if 1 <= month <= 12: - return f"{self._MONTH_ABBR[month - 1]} {int(parts[1])}" - return raw + if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): + return raw + month, day = int(parts[0]), int(parts[1]) + if not 1 <= month <= 12: + return raw + name = self._MONTH_ABBR[month - 1] + if fmt == "numeric_day_first": + return f"{day}/{month}" + if fmt == "day_first": + return f"{day} {name}" + if fmt == "weekday": + weekday = self._weekday_for(game) + return f"{weekday} {name} {day}" if weekday else f"{name} {day}" + return f"{name} {day}" + + def _weekday_for(self, game: Optional[Dict]) -> str: + """Weekday abbreviation from the game's start time, or ''.""" + if not game: + return "" + raw = game.get("start_time_utc") or game.get("start_time") + if not raw: + return "" + try: + start = raw if isinstance(raw, datetime) else datetime.fromisoformat( + str(raw).replace("Z", "+00:00")) + return self._WEEKDAY_ABBR[start.astimezone(self._card_tzinfo()).weekday()] + except (ValueError, TypeError): + return "" + + def _card_tzinfo(self): + """Timezone for weekday/24h conversions; falls back to UTC.""" + try: + configured = (self.config or {}).get("timezone") + if configured: + return ZoneInfo(configured) + except Exception: + pass + return timezone.utc + + def _format_game_time(self, time_text: str) -> str: + """Return the time as-is (12h) or converted to 24h.""" + raw = str(time_text or "").strip() + if not raw or str(self._scroll_card_option("time_format", "12h")) != "24h": + return raw + cleaned = raw.upper().replace(" ", "") + meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" + if not meridiem: + return raw + try: + hh, _, mm = cleaned[:-2].partition(":") + hour, minute = int(hh), int(mm or 0) + except ValueError: + return raw + if not (0 <= hour <= 12 and 0 <= minute <= 59): + return raw + hour = hour % 12 + (12 if meridiem == "PM" else 0) + return f"{hour:02d}:{minute:02d}" def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: """Draw the middle of an upcoming card. Never a score: an upcoming game has not started, so the extractor's - 0-0 is noise. Either "VS" (default) or the date and time stacked. + 0-0 is noise. Either the VS text (default), the date and time stacked, + or nothing at all. """ - if self._upcoming_center_mode() == "vs": - vs_text = "VS" + mode = self._upcoming_center_mode() + if mode == "none": + return + + if mode == "vs": + vs_text = self._vs_text() + if not vs_text: + return vs_width = draw.textlength(vs_text, font=self.fonts['score']) - vs_x = (self.display_width - vs_width) // 2 - vs_y = (self.display_height // 2) - 3 + vs_x = (self.display_width - vs_width) // 2 + self._layout_offset('score', 'x_offset') + vs_y = (self.display_height // 2) - 3 + self._layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts['score'] + draw, vs_text, (vs_x, vs_y), self.fonts['score'], + fill=self._element_color('score_text') ) return date_text, time_text = self._upcoming_date_and_time(game) - date_text = self._format_game_date(date_text) - font = self.fonts.get('detail') or self.fonts['time'] - lines = [t for t in (date_text, time_text) if t] + lines = [] + if self._scroll_card_option("show_date", True): + lines.append(self._format_game_date(date_text, game)) + if self._scroll_card_option("show_time", True): + lines.append(self._format_game_time(time_text)) + lines = [t for t in lines if t] if not lines: return + font = self.fonts.get('detail') or self.fonts['time'] line_h = 7 top = (self.display_height // 2) - (len(lines) * line_h) // 2 + top += self._layout_offset('score', 'y_offset') for i, line in enumerate(lines): width = draw.textlength(line, font=font) + x = (self.display_width - width) // 2 + self._layout_offset('score', 'x_offset') self._draw_text_with_outline( - draw, line, ((self.display_width - width) // 2, top + i * line_h), font + draw, line, (x, top + i * line_h), font, + fill=self._element_color('detail_text') ) def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: @@ -669,34 +792,55 @@ def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: ) def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: - """Draw date/time around an upcoming card: time top, date bottom. + """Draw the date and time around an upcoming card. - Skipped when the date and time are stacked in the middle instead -- - drawing both would print them twice. + Time top and date bottom by default; scroll_card.swap_date_time puts + the date on top instead. Skipped when the pair is stacked in the + middle, which would otherwise print them twice. """ - if self._upcoming_center_mode() != "vs": + if self._upcoming_center_mode() == "date_time": return - date_text, time_text = self._upcoming_date_and_time(game) - date_text = self._format_game_date(date_text) - - if time_text: - time_width = draw.textlength(time_text, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 + date_raw, time_raw = self._upcoming_date_and_time(game) + date_text = (self._format_game_date(date_raw, game) + if self._scroll_card_option("show_date", True) else "") + time_text = (self._format_game_time(time_raw) + if self._scroll_card_option("show_time", True) else "") + + if self._scroll_card_option("swap_date_time", False): + top_text, top_el, bottom_text, bottom_el = ( + date_text, 'date', time_text, 'time') + top_font = self.fonts.get('detail') or self.fonts['time'] + bottom_font = self.fonts['time'] + top_color, bottom_color = 'detail_text', 'period_text' + else: + top_text, top_el, bottom_text, bottom_el = ( + time_text, 'time', date_text, 'date') + top_font = self.fonts['time'] + bottom_font = self.fonts.get('detail') or self.fonts['time'] + top_color, bottom_color = 'period_text', 'detail_text' + + if top_text: + top_width = draw.textlength(top_text, font=top_font) + top_x = (self.display_width - top_width) // 2 + self._layout_offset(top_el, 'x_offset') + top_y = 1 + self._layout_offset(top_el, 'y_offset') self._draw_text_with_outline( - draw, time_text, (time_x, 1), self.fonts['time'] + draw, top_text, (top_x, top_y), top_font, + fill=self._element_color(top_color) ) - if date_text: - date_font = self.fonts.get('detail') or self.fonts['time'] - date_width = draw.textlength(date_text, font=date_font) - date_x = (self.display_width - date_width) // 2 + if bottom_text: + bottom_width = draw.textlength(bottom_text, font=bottom_font) + bottom_x = ((self.display_width - bottom_width) // 2 + + self._layout_offset(bottom_el, 'x_offset')) # Measured, not a fixed -7: the detail font is 6px in most plugins - # but 10px in soccer and nrl, where "Sep 19" ran 5px past the card. - date_bottom = draw.textbbox((0, 0), date_text, font=date_font)[3] - date_y = max(0, self.display_height - date_bottom - 1) + # but 10px in soccer and nrl, where "Sep 19" ran past the card. + ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] + bottom_y = (max(0, self.display_height - ink_bottom - 1) + + self._layout_offset(bottom_el, 'y_offset')) self._draw_text_with_outline( - draw, date_text, (date_x, date_y), date_font + draw, bottom_text, (bottom_x, bottom_y), bottom_font, + fill=self._element_color(bottom_color) ) def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None: diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 946b8f1c..5a30d84f 100644 --- a/plugins/nrl-scoreboard/manifest.json +++ b/plugins/nrl-scoreboard/manifest.json @@ -21,7 +21,7 @@ { "version": "1.5.0", "released": "2026-08-06", - "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged.", + "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged. Adds a fuller set of scroll_card settings: vs_text (VS, @, at, ...), date_format now covering abbrev/numeric/day_first/numeric_day_first/weekday, time_format 12h or 24h, show_date, show_time, swap_date_time, upcoming_center gains a 'none' option, and center_gap_ratio/min/max for the automatic gap. Each customization text element gains text_color, and the customization.layout X/Y offsets are now honoured by the scroll/Vegas card -- the schema advertised them but only the full-screen scoreboard read them before. The away team is drawn on the left and the home team on the right, so \"at\" and \"@\" read correctly as \"away at home\".", "ledmatrix_min_version": "2.0.0" }, { diff --git a/plugins/soccer-scoreboard/config_schema.json b/plugins/soccer-scoreboard/config_schema.json index 92576f2f..15cf1521 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -13,29 +13,95 @@ "upcoming_center": { "type": "string", "title": "Middle of an Upcoming Card", - "description": "What to show between the two logos before a game starts: VS, or the date and time stacked in the middle. Upcoming games never show a score, since the game has not been played.", + "description": "What to show between the two logos before a game starts. Upcoming games never show a score, since the game has not been played.", "enum": [ "vs", - "date_time" + "date_time", + "none" ], "default": "vs" }, + "vs_text": { + "type": "string", + "title": "Matchup Separator", + "description": "Text drawn between the two teams, e.g. VS, @, at, v. The away team is always on the left and the home team on the right, so \"at\" and \"@\" read correctly as \"away at home\". Leave blank to draw nothing.", + "default": "VS", + "maxLength": 4 + }, "date_format": { "type": "string", "title": "Date Format", - "description": "How to write the date on an upcoming card: abbrev shows \"Sep 19\", numeric shows \"9/19\".", + "description": "How to write the date: abbrev \"Sep 19\", numeric \"9/19\", day_first \"19 Sep\", numeric_day_first \"19/9\", weekday \"Fri Sep 19\".", "enum": [ "abbrev", - "numeric" + "numeric", + "day_first", + "numeric_day_first", + "weekday" ], "default": "abbrev" }, + "time_format": { + "type": "string", + "title": "Time Format", + "description": "12h shows \"7:00PM\"; 24h shows \"19:00\".", + "enum": [ + "12h", + "24h" + ], + "default": "12h" + }, + "show_date": { + "type": "boolean", + "title": "Show Date", + "description": "Draw the date on upcoming cards.", + "default": true + }, + "show_time": { + "type": "boolean", + "title": "Show Time", + "description": "Draw the start time on upcoming cards.", + "default": true + }, + "swap_date_time": { + "type": "boolean", + "title": "Swap Date and Time", + "description": "Put the date on top and the time along the bottom instead of the default.", + "default": false + }, "center_gap": { "type": "integer", "title": "Center Gap", - "description": "Pixels kept clear down the middle of a card so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. Set to 0 for the old edge-to-edge logos.", + "description": "Pixels kept clear down the middle so the score or VS is not drawn over the team logos. Leave unset to scale with the card width. 0 restores the old edge-to-edge logos.", "minimum": 0, "maximum": 64 + }, + "center_gap_ratio": { + "type": "number", + "title": "Center Gap Ratio", + "description": "Fraction of card width used for the centre gap when it is not pinned.", + "minimum": 0.0, + "maximum": 0.6, + "default": 0.28, + "x-advanced": true + }, + "center_gap_min": { + "type": "integer", + "title": "Center Gap Minimum", + "description": "Lower bound in pixels for the scaled centre gap.", + "minimum": 0, + "maximum": 64, + "default": 22, + "x-advanced": true + }, + "center_gap_max": { + "type": "integer", + "title": "Center Gap Maximum", + "description": "Upper bound in pixels for the scaled centre gap.", + "minimum": 0, + "maximum": 96, + "default": 40, + "x-advanced": true } } }, @@ -553,7 +619,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -962,7 +1028,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -1371,7 +1437,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -1780,7 +1846,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -2189,7 +2255,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -2598,7 +2664,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -3007,7 +3073,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -3416,7 +3482,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -3825,7 +3891,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -4234,7 +4300,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -4282,7 +4348,7 @@ "custom_leagues": { "type": "array", "title": "Add More Leagues", - "description": "Add any soccer league available on ESPN. Click 'Add Item', then fill in BOTH a name and a league code. Common codes: eng.2 (English Championship), eng.3 (League One), eng.fa (FA Cup), eng.league_cup (EFL Cup), mex.1 (Liga MX), arg.1 (Argentina), bra.1 (Brazil), ned.1 (Eredivisie), sco.1 (Scottish Premiership), tur.1 (Turkish Süper Lig), bel.1 (Belgian Pro League)", + "description": "Add any soccer league available on ESPN. Click 'Add Item', then fill in BOTH a name and a league code. Common codes: eng.2 (English Championship), eng.3 (League One), eng.fa (FA Cup), eng.league_cup (EFL Cup), mex.1 (Liga MX), arg.1 (Argentina), bra.1 (Brazil), ned.1 (Eredivisie), sco.1 (Scottish Premiership), tur.1 (Turkish S\u00fcper Lig), bel.1 (Belgian Pro League)", "x-widget": "array-table", "x-columns": [ "name", @@ -4310,7 +4376,10 @@ "description": "ESPN league code in dot-separated format (e.g., 'eng.2', 'mex.1', 'uefa.champions', 'eng.league_cup', 'conmebol.libertadores')" }, "priority": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": 50, "minimum": 1, "maximum": 100, @@ -4322,7 +4391,11 @@ "description": "Whether this league is enabled" }, "favorite_teams": { - "type": ["array", "string", "null"], + "type": [ + "array", + "string", + "null" + ], "items": { "type": "string" }, @@ -4332,7 +4405,11 @@ "description": "Favorite team abbreviations for this league (comma-separated when typed into the row editor)" }, "exclude_teams": { - "type": ["array", "string", "null"], + "type": [ + "array", + "string", + "null" + ], "items": { "type": "string", "description": "Custom league team name or abbreviation" @@ -4403,7 +4480,10 @@ "additionalProperties": false }, "live_game_duration": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": 20, "minimum": 10, "maximum": 120, @@ -4411,7 +4491,10 @@ "x-advanced": true }, "non_favorite_live_game_duration": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": 0, "minimum": 0, "maximum": 120, @@ -4419,7 +4502,10 @@ "x-advanced": true }, "recent_game_duration": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": 15, "minimum": 5, "maximum": 60, @@ -4427,7 +4513,10 @@ "x-advanced": true }, "upcoming_game_duration": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": 15, "minimum": 5, "maximum": 60, @@ -4440,14 +4529,20 @@ "description": "Control how many games to show", "properties": { "recent_games_to_show": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": 1, "minimum": 1, "maximum": 20, "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." }, "upcoming_games_to_show": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": 10, "minimum": 1, "maximum": 20, @@ -4472,7 +4567,10 @@ "description": "Show all live games, not just favorites" }, "favorite_live_boost": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "minimum": 1, "maximum": 5, "default": 2, @@ -4494,7 +4592,10 @@ "x-advanced": true }, "min_duration_seconds": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "minimum": 10, "maximum": 300, "default": 30, @@ -4502,14 +4603,20 @@ "x-advanced": true }, "max_duration_seconds": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "minimum": 60, "maximum": 600, "description": "Maximum total duration in seconds", "x-advanced": true }, "modes": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "title": "Per-Mode Settings", "description": "Configure dynamic duration for specific modes", "properties": { @@ -4523,13 +4630,19 @@ "x-advanced": true }, "min_duration_seconds": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "minimum": 10, "maximum": 300, "x-advanced": true }, "max_duration_seconds": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "minimum": 60, "maximum": 600, "x-advanced": true @@ -4547,13 +4660,19 @@ "x-advanced": true }, "min_duration_seconds": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "minimum": 10, "maximum": 300, "x-advanced": true }, "max_duration_seconds": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "minimum": 60, "maximum": 600, "x-advanced": true @@ -4571,13 +4690,19 @@ "x-advanced": true }, "min_duration_seconds": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "minimum": 10, "maximum": 300, "x-advanced": true }, "max_duration_seconds": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "minimum": 60, "maximum": 600, "x-advanced": true @@ -4630,6 +4755,25 @@ "minimum": 4, "maximum": 16, "default": 10 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the score text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -4664,6 +4808,25 @@ "minimum": 4, "maximum": 16, "default": 8 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the period text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -4698,6 +4861,25 @@ "minimum": 4, "maximum": 16, "default": 8 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the team name on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -4732,6 +4914,25 @@ "minimum": 4, "maximum": 16, "default": 6 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the status text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -4766,6 +4967,25 @@ "minimum": 4, "maximum": 16, "default": 6 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the detail text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -4800,6 +5020,25 @@ "minimum": 4, "maximum": 16, "default": 10 + }, + "text_color": { + "type": "array", + "title": "Text Color", + "description": "Colour [R, G, B] for the rank text on scroll/Vegas cards.", + "x-widget": "color-picker", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true } }, "x-propertyOrder": [ @@ -4972,7 +5211,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [0, 255, 0], + "default": [ + 0, + 255, + 0 + ], "x-advanced": true }, "loss_color": { @@ -4987,7 +5230,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 0, 0], + "default": [ + 255, + 0, + 0 + ], "x-advanced": true }, "tie_color": { @@ -5002,7 +5249,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 200, 0], + "default": [ + 255, + 200, + 0 + ], "x-advanced": true } }, @@ -5027,7 +5278,7 @@ ], "additionalProperties": false } -}, + }, "additionalProperties": false, "required": [ "enabled" diff --git a/plugins/soccer-scoreboard/game_renderer.py b/plugins/soccer-scoreboard/game_renderer.py index 657ab131..98036276 100644 --- a/plugins/soccer-scoreboard/game_renderer.py +++ b/plugins/soccer-scoreboard/game_renderer.py @@ -471,12 +471,16 @@ def render_game_card( # Place logos — each centered within a slot on its side; cap at half the card # width so home_slot_start stays non-negative on square/tall displays logo_slot = self._logo_slot_width() - away_x = (logo_slot - away_logo.width) // 2 - away_y = center_y - (away_logo.height // 2) + away_x = ((logo_slot - away_logo.width) // 2 + + self._layout_offset('away_logo', 'x_offset')) + away_y = (center_y - (away_logo.height // 2) + + self._layout_offset('away_logo', 'y_offset')) home_slot_start = self.display_width - logo_slot - home_x = home_slot_start + (logo_slot - home_logo.width) // 2 - home_y = center_y - (home_logo.height // 2) + home_x = (home_slot_start + (logo_slot - home_logo.width) // 2 + + self._layout_offset('home_logo', 'x_offset')) + home_y = (center_y - (home_logo.height // 2) + + self._layout_offset('home_logo', 'y_offset')) # Draw logos main_img.paste(home_logo, (home_x, home_y), home_logo) @@ -485,8 +489,10 @@ def render_game_card( # Draw scores (centered) — only once a game has started. Upcoming games # have no score, so the extractor's 0-0 was pure noise. if game_type in ("live", "recent"): - score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 + score_x = ((self.display_width - score_width) // 2 + + self._layout_offset('score', 'x_offset')) + score_y = ((self.display_height // 2) - 3 + + self._layout_offset('score', 'y_offset')) self._draw_text_with_outline( draw_overlay, score_text, (score_x, score_y), self.fonts['score'], fill=self._score_color_for(game, game_type) @@ -561,22 +567,23 @@ def _draw_recent_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['detail']) # ------------------------------------------------------------------ - # Scroll/Vegas card options -- config["scroll_card"]. + # Scroll/Vegas card options -- config["scroll_card"], plus the shared + # customization.layout offsets and per-element colours. # # These only affect the cards this renderer builds, which are used by # scroll_display.py and scroll_display_legacy.py alone. The full-screen # scorebug is drawn elsewhere and is deliberately left untouched. # ------------------------------------------------------------------ - # Middle strip kept clear of logos so the score / "VS" is never drawn on - # top of them. 0.28 of a 128px card clears "1-2" (30px) with room spare. CENTER_GAP_RATIO: ClassVar[float] = 0.28 - # 22 so "VS" (20px) still clears the logos on the narrowest 64px card. CENTER_GAP_MIN_PX: ClassVar[int] = 22 CENTER_GAP_MAX_PX: ClassVar[int] = 40 _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ) + _WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = ( + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", + ) def _logo_cache_key(self, name: str) -> str: """Cache key scoped to the logo slot. @@ -593,16 +600,58 @@ def _scroll_card_option(self, key: str, default: Any = None) -> Any: return block.get(key) return default + def _layout_offset(self, element: str, axis: str, default: int = 0) -> int: + """X/Y nudge for one element, from customization.layout. + + Same block the full-screen scorebug reads (sports.py + _get_layout_offset), so a nudge configured in the web UI now moves + the element on the scroll/Vegas card too -- previously the schema + advertised these offsets but this renderer ignored them. + """ + try: + layout = (self.config or {}).get("customization", {}).get("layout", {}) + value = (layout.get(element) or {}).get(axis, default) + if isinstance(value, bool): + return default + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + return int(float(value)) + except (TypeError, ValueError): + pass + return default + + def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): + """Per-element text colour from customization..text_color.""" + try: + cfg = (self.config or {}).get("customization", {}).get(element, {}) + value = cfg.get("text_color") + if isinstance(value, (list, tuple)) and len(value) == 3: + return tuple(max(0, min(255, int(c))) for c in value) + if isinstance(value, str) and value.startswith("#") and len(value) == 7: + return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) + except (TypeError, ValueError): + pass + return default + def _center_gap_width(self) -> int: """Width of the middle strip kept clear of logos. - ``scroll_card.center_gap`` overrides it; 0 restores edge-to-edge logos. + ``scroll_card.center_gap`` pins it outright; otherwise it scales with + the card width between the configurable min and max. 0 restores + edge-to-edge logos. """ configured = self._scroll_card_option("center_gap") if isinstance(configured, (int, float)) and configured >= 0: return int(configured) - scaled = round(self.display_width * self.CENTER_GAP_RATIO) - return int(max(self.CENTER_GAP_MIN_PX, min(self.CENTER_GAP_MAX_PX, scaled))) + ratio = self._scroll_card_option("center_gap_ratio", self.CENTER_GAP_RATIO) + low = self._scroll_card_option("center_gap_min", self.CENTER_GAP_MIN_PX) + high = self._scroll_card_option("center_gap_max", self.CENTER_GAP_MAX_PX) + try: + scaled = round(self.display_width * float(ratio)) + return int(max(int(low), min(int(high), scaled))) + except (TypeError, ValueError): + return self.CENTER_GAP_MIN_PX def _logo_slot_width(self) -> int: """Per-side logo slot, leaving the center gap clear. @@ -615,50 +664,124 @@ def _logo_slot_width(self) -> int: return max(8, min(self.display_height, available)) def _upcoming_center_mode(self) -> str: - """What sits in the middle of an upcoming card: 'vs' or 'date_time'.""" + """Middle of an upcoming card: 'vs', 'date_time' or 'none'.""" mode = str(self._scroll_card_option("upcoming_center", "vs") or "vs").lower() - return mode if mode in ("vs", "date_time") else "vs" + return mode if mode in ("vs", "date_time", "none") else "vs" + + def _vs_text(self) -> str: + """Separator drawn between the teams -- "VS", "@", "at", anything.""" + return str(self._scroll_card_option("vs_text", "VS")) - def _format_game_date(self, date_text: str) -> str: - """Render a date as "Sep 19" (default) or "9/19" (``date_format``).""" + def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: + """Format an upcoming card's date per scroll_card.date_format.""" raw = str(date_text or "").strip() - if not raw or self._scroll_card_option("date_format", "abbrev") == "numeric": + if not raw: + return "" + fmt = str(self._scroll_card_option("date_format", "abbrev") or "abbrev") + if fmt == "numeric": return raw parts = raw.replace("-", "/").split("/") - if len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): - month = int(parts[0]) - if 1 <= month <= 12: - return f"{self._MONTH_ABBR[month - 1]} {int(parts[1])}" - return raw + if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): + return raw + month, day = int(parts[0]), int(parts[1]) + if not 1 <= month <= 12: + return raw + name = self._MONTH_ABBR[month - 1] + if fmt == "numeric_day_first": + return f"{day}/{month}" + if fmt == "day_first": + return f"{day} {name}" + if fmt == "weekday": + weekday = self._weekday_for(game) + return f"{weekday} {name} {day}" if weekday else f"{name} {day}" + return f"{name} {day}" + + def _weekday_for(self, game: Optional[Dict]) -> str: + """Weekday abbreviation from the game's start time, or ''.""" + if not game: + return "" + raw = game.get("start_time_utc") or game.get("start_time") + if not raw: + return "" + try: + start = raw if isinstance(raw, datetime) else datetime.fromisoformat( + str(raw).replace("Z", "+00:00")) + return self._WEEKDAY_ABBR[start.astimezone(self._card_tzinfo()).weekday()] + except (ValueError, TypeError): + return "" + + def _card_tzinfo(self): + """Timezone for weekday/24h conversions; falls back to UTC.""" + try: + configured = (self.config or {}).get("timezone") + if configured: + return ZoneInfo(configured) + except Exception: + pass + return timezone.utc + + def _format_game_time(self, time_text: str) -> str: + """Return the time as-is (12h) or converted to 24h.""" + raw = str(time_text or "").strip() + if not raw or str(self._scroll_card_option("time_format", "12h")) != "24h": + return raw + cleaned = raw.upper().replace(" ", "") + meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" + if not meridiem: + return raw + try: + hh, _, mm = cleaned[:-2].partition(":") + hour, minute = int(hh), int(mm or 0) + except ValueError: + return raw + if not (0 <= hour <= 12 and 0 <= minute <= 59): + return raw + hour = hour % 12 + (12 if meridiem == "PM" else 0) + return f"{hour:02d}:{minute:02d}" def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: """Draw the middle of an upcoming card. Never a score: an upcoming game has not started, so the extractor's - 0-0 is noise. Either "VS" (default) or the date and time stacked. + 0-0 is noise. Either the VS text (default), the date and time stacked, + or nothing at all. """ - if self._upcoming_center_mode() == "vs": - vs_text = "VS" + mode = self._upcoming_center_mode() + if mode == "none": + return + + if mode == "vs": + vs_text = self._vs_text() + if not vs_text: + return vs_width = draw.textlength(vs_text, font=self.fonts['score']) - vs_x = (self.display_width - vs_width) // 2 - vs_y = (self.display_height // 2) - 3 + vs_x = (self.display_width - vs_width) // 2 + self._layout_offset('score', 'x_offset') + vs_y = (self.display_height // 2) - 3 + self._layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts['score'] + draw, vs_text, (vs_x, vs_y), self.fonts['score'], + fill=self._element_color('score_text') ) return date_text, time_text = self._upcoming_date_and_time(game) - date_text = self._format_game_date(date_text) - font = self.fonts.get('detail') or self.fonts['time'] - lines = [t for t in (date_text, time_text) if t] + lines = [] + if self._scroll_card_option("show_date", True): + lines.append(self._format_game_date(date_text, game)) + if self._scroll_card_option("show_time", True): + lines.append(self._format_game_time(time_text)) + lines = [t for t in lines if t] if not lines: return + font = self.fonts.get('detail') or self.fonts['time'] line_h = 7 top = (self.display_height // 2) - (len(lines) * line_h) // 2 + top += self._layout_offset('score', 'y_offset') for i, line in enumerate(lines): width = draw.textlength(line, font=font) + x = (self.display_width - width) // 2 + self._layout_offset('score', 'x_offset') self._draw_text_with_outline( - draw, line, ((self.display_width - width) // 2, top + i * line_h), font + draw, line, (x, top + i * line_h), font, + fill=self._element_color('detail_text') ) def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: @@ -669,34 +792,55 @@ def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: ) def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: - """Draw date/time around an upcoming card: time top, date bottom. + """Draw the date and time around an upcoming card. - Skipped when the date and time are stacked in the middle instead -- - drawing both would print them twice. + Time top and date bottom by default; scroll_card.swap_date_time puts + the date on top instead. Skipped when the pair is stacked in the + middle, which would otherwise print them twice. """ - if self._upcoming_center_mode() != "vs": + if self._upcoming_center_mode() == "date_time": return - date_text, time_text = self._upcoming_date_and_time(game) - date_text = self._format_game_date(date_text) - - if time_text: - time_width = draw.textlength(time_text, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 + date_raw, time_raw = self._upcoming_date_and_time(game) + date_text = (self._format_game_date(date_raw, game) + if self._scroll_card_option("show_date", True) else "") + time_text = (self._format_game_time(time_raw) + if self._scroll_card_option("show_time", True) else "") + + if self._scroll_card_option("swap_date_time", False): + top_text, top_el, bottom_text, bottom_el = ( + date_text, 'date', time_text, 'time') + top_font = self.fonts.get('detail') or self.fonts['time'] + bottom_font = self.fonts['time'] + top_color, bottom_color = 'detail_text', 'period_text' + else: + top_text, top_el, bottom_text, bottom_el = ( + time_text, 'time', date_text, 'date') + top_font = self.fonts['time'] + bottom_font = self.fonts.get('detail') or self.fonts['time'] + top_color, bottom_color = 'period_text', 'detail_text' + + if top_text: + top_width = draw.textlength(top_text, font=top_font) + top_x = (self.display_width - top_width) // 2 + self._layout_offset(top_el, 'x_offset') + top_y = 1 + self._layout_offset(top_el, 'y_offset') self._draw_text_with_outline( - draw, time_text, (time_x, 1), self.fonts['time'] + draw, top_text, (top_x, top_y), top_font, + fill=self._element_color(top_color) ) - if date_text: - date_font = self.fonts.get('detail') or self.fonts['time'] - date_width = draw.textlength(date_text, font=date_font) - date_x = (self.display_width - date_width) // 2 + if bottom_text: + bottom_width = draw.textlength(bottom_text, font=bottom_font) + bottom_x = ((self.display_width - bottom_width) // 2 + + self._layout_offset(bottom_el, 'x_offset')) # Measured, not a fixed -7: the detail font is 6px in most plugins - # but 10px in soccer and nrl, where "Sep 19" ran 5px past the card. - date_bottom = draw.textbbox((0, 0), date_text, font=date_font)[3] - date_y = max(0, self.display_height - date_bottom - 1) + # but 10px in soccer and nrl, where "Sep 19" ran past the card. + ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] + bottom_y = (max(0, self.display_height - ink_bottom - 1) + + self._layout_offset(bottom_el, 'y_offset')) self._draw_text_with_outline( - draw, date_text, (date_x, date_y), date_font + draw, bottom_text, (bottom_x, bottom_y), bottom_font, + fill=self._element_color(bottom_color) ) def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None: diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 358245f5..44708617 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -29,7 +29,7 @@ { "version": "2.8.0", "released": "2026-08-06", - "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged.", + "notes": "Scroll and Vegas cards: never show 0-0 before a game starts, add a centre gap so the score or VS is not drawn on top of the team logos, and write upcoming dates as \"Sep 19\". A new scroll_card config block sets what fills the middle of an upcoming card (VS, or the date and time stacked), the date format (abbrev/numeric) and the centre gap in pixels. gap_between_games is now honoured in Vegas mode, which stitches its own items and previously ignored it, and its code default moves from 24 to 48 to match the config schema. The date is positioned from its measured height rather than a fixed offset, which stops \"Sep 19\" running past the bottom of the card in the plugins whose detail font is 10px. These settings only affect the cards built for scroll and Vegas modes; the full-screen scoreboard is drawn by a separate code path and is unchanged. Adds a fuller set of scroll_card settings: vs_text (VS, @, at, ...), date_format now covering abbrev/numeric/day_first/numeric_day_first/weekday, time_format 12h or 24h, show_date, show_time, swap_date_time, upcoming_center gains a 'none' option, and center_gap_ratio/min/max for the automatic gap. Each customization text element gains text_color, and the customization.layout X/Y offsets are now honoured by the scroll/Vegas card -- the schema advertised them but only the full-screen scoreboard read them before. The away team is drawn on the left and the home team on the right, so \"at\" and \"@\" read correctly as \"away at home\".", "ledmatrix_min_version": "2.0.0" }, { From 2c5cb88900fc2747431bc350ac7cab2a186cf83d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 18:22:00 -0400 Subject: [PATCH 7/9] fix(sports): make text_color reachable in the web UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each customization element carries x-propertyOrder ["font", "font_size"], and the config form renders that list rather than the property set — so the text_color field added alongside them was present in the schema, valid, and completely invisible in the UI. Appending it to the order makes it render. Caught by fetching the rendered config partial from the running web service rather than reading the schema: text_color appeared 0 times in the HTML for a schema that declared it six times. baseball is untouched here because its elements have no x-propertyOrder, so its text_color fields were already rendering. --- plugins/afl-scoreboard/config_schema.json | 18 ++++++++++++------ .../basketball-scoreboard/config_schema.json | 18 ++++++++++++------ plugins/football-scoreboard/config_schema.json | 18 ++++++++++++------ plugins/hockey-scoreboard/config_schema.json | 18 ++++++++++++------ plugins/lacrosse-scoreboard/config_schema.json | 18 ++++++++++++------ plugins/nrl-scoreboard/config_schema.json | 18 ++++++++++++------ plugins/soccer-scoreboard/config_schema.json | 18 ++++++++++++------ 7 files changed, 84 insertions(+), 42 deletions(-) diff --git a/plugins/afl-scoreboard/config_schema.json b/plugins/afl-scoreboard/config_schema.json index 7d0389a6..1b0d9f4e 100644 --- a/plugins/afl-scoreboard/config_schema.json +++ b/plugins/afl-scoreboard/config_schema.json @@ -681,7 +681,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -736,7 +737,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -791,7 +793,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -846,7 +849,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -901,7 +905,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -956,7 +961,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, diff --git a/plugins/basketball-scoreboard/config_schema.json b/plugins/basketball-scoreboard/config_schema.json index 35585c31..30ce4efc 100644 --- a/plugins/basketball-scoreboard/config_schema.json +++ b/plugins/basketball-scoreboard/config_schema.json @@ -1850,7 +1850,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1905,7 +1906,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1960,7 +1962,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -2015,7 +2018,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -2070,7 +2074,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -2125,7 +2130,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, diff --git a/plugins/football-scoreboard/config_schema.json b/plugins/football-scoreboard/config_schema.json index f69b2534..722c0099 100644 --- a/plugins/football-scoreboard/config_schema.json +++ b/plugins/football-scoreboard/config_schema.json @@ -959,7 +959,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1014,7 +1015,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1069,7 +1071,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1124,7 +1127,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1179,7 +1183,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1234,7 +1239,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, diff --git a/plugins/hockey-scoreboard/config_schema.json b/plugins/hockey-scoreboard/config_schema.json index 8392ef3b..2240233e 100644 --- a/plugins/hockey-scoreboard/config_schema.json +++ b/plugins/hockey-scoreboard/config_schema.json @@ -1512,7 +1512,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1564,7 +1565,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1616,7 +1618,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1668,7 +1671,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1720,7 +1724,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1772,7 +1777,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, diff --git a/plugins/lacrosse-scoreboard/config_schema.json b/plugins/lacrosse-scoreboard/config_schema.json index 5de602cb..2070f787 100644 --- a/plugins/lacrosse-scoreboard/config_schema.json +++ b/plugins/lacrosse-scoreboard/config_schema.json @@ -1055,7 +1055,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1107,7 +1108,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1159,7 +1161,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1211,7 +1214,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1263,7 +1267,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1315,7 +1320,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, diff --git a/plugins/nrl-scoreboard/config_schema.json b/plugins/nrl-scoreboard/config_schema.json index 8b2bff5f..12dce9c5 100644 --- a/plugins/nrl-scoreboard/config_schema.json +++ b/plugins/nrl-scoreboard/config_schema.json @@ -675,7 +675,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -728,7 +729,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -781,7 +783,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -834,7 +837,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -887,7 +891,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -940,7 +945,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, diff --git a/plugins/soccer-scoreboard/config_schema.json b/plugins/soccer-scoreboard/config_schema.json index 15cf1521..fb074318 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -4778,7 +4778,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -4831,7 +4832,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -4884,7 +4886,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -4937,7 +4940,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -4990,7 +4994,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -5043,7 +5048,8 @@ }, "x-propertyOrder": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, From 43c053b50ae8a98034f3720ad8f6d77ac2a9317e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 18:48:18 -0400 Subject: [PATCH 8/9] feat(sports): label the scroll_card dropdown options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config form derives an enum's option text from its value, so the dropdowns read "Vs", "Abbrev" and "Numeric Day First" — accurate to the config key and useless as a description of what you get. Supplying x-options.labels makes each option show its own output: date_format Sep 19 | 9/19 | 19 Sep | 19/9 | Fri Sep 19 time_format 7:00PM (12-hour) | 19:00 (24-hour) upcoming_center Matchup separator (VS / @ / at) | Date and time, stacked | Nothing Labels are display only, so no saved config changes. Cores that predate x-options.labels support for plain enums (LEDMatrix PR #442) ignore the key and fall back to the humanised value, which is the behaviour these dropdowns have today -- verified against a device running the older template, so this is safe to ship ahead of that core change. --- plugins/afl-scoreboard/config_schema.json | 28 +++++++++++++++++-- .../baseball-scoreboard/config_schema.json | 28 +++++++++++++++++-- .../basketball-scoreboard/config_schema.json | 28 +++++++++++++++++-- .../football-scoreboard/config_schema.json | 28 +++++++++++++++++-- plugins/hockey-scoreboard/config_schema.json | 28 +++++++++++++++++-- .../lacrosse-scoreboard/config_schema.json | 28 +++++++++++++++++-- plugins/nrl-scoreboard/config_schema.json | 28 +++++++++++++++++-- plugins/soccer-scoreboard/config_schema.json | 28 +++++++++++++++++-- 8 files changed, 200 insertions(+), 24 deletions(-) diff --git a/plugins/afl-scoreboard/config_schema.json b/plugins/afl-scoreboard/config_schema.json index 1b0d9f4e..b9338238 100644 --- a/plugins/afl-scoreboard/config_schema.json +++ b/plugins/afl-scoreboard/config_schema.json @@ -19,7 +19,14 @@ "date_time", "none" ], - "default": "vs" + "default": "vs", + "x-options": { + "labels": { + "vs": "Matchup separator (VS / @ / at)", + "date_time": "Date and time, stacked", + "none": "Nothing" + } + } }, "vs_text": { "type": "string", @@ -39,7 +46,16 @@ "numeric_day_first", "weekday" ], - "default": "abbrev" + "default": "abbrev", + "x-options": { + "labels": { + "abbrev": "Sep 19", + "numeric": "9/19", + "day_first": "19 Sep", + "numeric_day_first": "19/9", + "weekday": "Fri Sep 19" + } + } }, "time_format": { "type": "string", @@ -49,7 +65,13 @@ "12h", "24h" ], - "default": "12h" + "default": "12h", + "x-options": { + "labels": { + "12h": "7:00PM (12-hour)", + "24h": "19:00 (24-hour)" + } + } }, "show_date": { "type": "boolean", diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index 23079f3f..74d0029c 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -19,7 +19,14 @@ "date_time", "none" ], - "default": "vs" + "default": "vs", + "x-options": { + "labels": { + "vs": "Matchup separator (VS / @ / at)", + "date_time": "Date and time, stacked", + "none": "Nothing" + } + } }, "vs_text": { "type": "string", @@ -39,7 +46,16 @@ "numeric_day_first", "weekday" ], - "default": "abbrev" + "default": "abbrev", + "x-options": { + "labels": { + "abbrev": "Sep 19", + "numeric": "9/19", + "day_first": "19 Sep", + "numeric_day_first": "19/9", + "weekday": "Fri Sep 19" + } + } }, "time_format": { "type": "string", @@ -49,7 +65,13 @@ "12h", "24h" ], - "default": "12h" + "default": "12h", + "x-options": { + "labels": { + "12h": "7:00PM (12-hour)", + "24h": "19:00 (24-hour)" + } + } }, "show_date": { "type": "boolean", diff --git a/plugins/basketball-scoreboard/config_schema.json b/plugins/basketball-scoreboard/config_schema.json index 30ce4efc..7f056547 100644 --- a/plugins/basketball-scoreboard/config_schema.json +++ b/plugins/basketball-scoreboard/config_schema.json @@ -19,7 +19,14 @@ "date_time", "none" ], - "default": "vs" + "default": "vs", + "x-options": { + "labels": { + "vs": "Matchup separator (VS / @ / at)", + "date_time": "Date and time, stacked", + "none": "Nothing" + } + } }, "vs_text": { "type": "string", @@ -39,7 +46,16 @@ "numeric_day_first", "weekday" ], - "default": "abbrev" + "default": "abbrev", + "x-options": { + "labels": { + "abbrev": "Sep 19", + "numeric": "9/19", + "day_first": "19 Sep", + "numeric_day_first": "19/9", + "weekday": "Fri Sep 19" + } + } }, "time_format": { "type": "string", @@ -49,7 +65,13 @@ "12h", "24h" ], - "default": "12h" + "default": "12h", + "x-options": { + "labels": { + "12h": "7:00PM (12-hour)", + "24h": "19:00 (24-hour)" + } + } }, "show_date": { "type": "boolean", diff --git a/plugins/football-scoreboard/config_schema.json b/plugins/football-scoreboard/config_schema.json index 722c0099..1297fe21 100644 --- a/plugins/football-scoreboard/config_schema.json +++ b/plugins/football-scoreboard/config_schema.json @@ -19,7 +19,14 @@ "date_time", "none" ], - "default": "vs" + "default": "vs", + "x-options": { + "labels": { + "vs": "Matchup separator (VS / @ / at)", + "date_time": "Date and time, stacked", + "none": "Nothing" + } + } }, "vs_text": { "type": "string", @@ -39,7 +46,16 @@ "numeric_day_first", "weekday" ], - "default": "abbrev" + "default": "abbrev", + "x-options": { + "labels": { + "abbrev": "Sep 19", + "numeric": "9/19", + "day_first": "19 Sep", + "numeric_day_first": "19/9", + "weekday": "Fri Sep 19" + } + } }, "time_format": { "type": "string", @@ -49,7 +65,13 @@ "12h", "24h" ], - "default": "12h" + "default": "12h", + "x-options": { + "labels": { + "12h": "7:00PM (12-hour)", + "24h": "19:00 (24-hour)" + } + } }, "show_date": { "type": "boolean", diff --git a/plugins/hockey-scoreboard/config_schema.json b/plugins/hockey-scoreboard/config_schema.json index 2240233e..e6c6ca86 100644 --- a/plugins/hockey-scoreboard/config_schema.json +++ b/plugins/hockey-scoreboard/config_schema.json @@ -19,7 +19,14 @@ "date_time", "none" ], - "default": "vs" + "default": "vs", + "x-options": { + "labels": { + "vs": "Matchup separator (VS / @ / at)", + "date_time": "Date and time, stacked", + "none": "Nothing" + } + } }, "vs_text": { "type": "string", @@ -39,7 +46,16 @@ "numeric_day_first", "weekday" ], - "default": "abbrev" + "default": "abbrev", + "x-options": { + "labels": { + "abbrev": "Sep 19", + "numeric": "9/19", + "day_first": "19 Sep", + "numeric_day_first": "19/9", + "weekday": "Fri Sep 19" + } + } }, "time_format": { "type": "string", @@ -49,7 +65,13 @@ "12h", "24h" ], - "default": "12h" + "default": "12h", + "x-options": { + "labels": { + "12h": "7:00PM (12-hour)", + "24h": "19:00 (24-hour)" + } + } }, "show_date": { "type": "boolean", diff --git a/plugins/lacrosse-scoreboard/config_schema.json b/plugins/lacrosse-scoreboard/config_schema.json index 2070f787..d5c6d783 100644 --- a/plugins/lacrosse-scoreboard/config_schema.json +++ b/plugins/lacrosse-scoreboard/config_schema.json @@ -19,7 +19,14 @@ "date_time", "none" ], - "default": "vs" + "default": "vs", + "x-options": { + "labels": { + "vs": "Matchup separator (VS / @ / at)", + "date_time": "Date and time, stacked", + "none": "Nothing" + } + } }, "vs_text": { "type": "string", @@ -39,7 +46,16 @@ "numeric_day_first", "weekday" ], - "default": "abbrev" + "default": "abbrev", + "x-options": { + "labels": { + "abbrev": "Sep 19", + "numeric": "9/19", + "day_first": "19 Sep", + "numeric_day_first": "19/9", + "weekday": "Fri Sep 19" + } + } }, "time_format": { "type": "string", @@ -49,7 +65,13 @@ "12h", "24h" ], - "default": "12h" + "default": "12h", + "x-options": { + "labels": { + "12h": "7:00PM (12-hour)", + "24h": "19:00 (24-hour)" + } + } }, "show_date": { "type": "boolean", diff --git a/plugins/nrl-scoreboard/config_schema.json b/plugins/nrl-scoreboard/config_schema.json index 12dce9c5..f68ad7d1 100644 --- a/plugins/nrl-scoreboard/config_schema.json +++ b/plugins/nrl-scoreboard/config_schema.json @@ -19,7 +19,14 @@ "date_time", "none" ], - "default": "vs" + "default": "vs", + "x-options": { + "labels": { + "vs": "Matchup separator (VS / @ / at)", + "date_time": "Date and time, stacked", + "none": "Nothing" + } + } }, "vs_text": { "type": "string", @@ -39,7 +46,16 @@ "numeric_day_first", "weekday" ], - "default": "abbrev" + "default": "abbrev", + "x-options": { + "labels": { + "abbrev": "Sep 19", + "numeric": "9/19", + "day_first": "19 Sep", + "numeric_day_first": "19/9", + "weekday": "Fri Sep 19" + } + } }, "time_format": { "type": "string", @@ -49,7 +65,13 @@ "12h", "24h" ], - "default": "12h" + "default": "12h", + "x-options": { + "labels": { + "12h": "7:00PM (12-hour)", + "24h": "19:00 (24-hour)" + } + } }, "show_date": { "type": "boolean", diff --git a/plugins/soccer-scoreboard/config_schema.json b/plugins/soccer-scoreboard/config_schema.json index fb074318..9e8ba441 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -19,7 +19,14 @@ "date_time", "none" ], - "default": "vs" + "default": "vs", + "x-options": { + "labels": { + "vs": "Matchup separator (VS / @ / at)", + "date_time": "Date and time, stacked", + "none": "Nothing" + } + } }, "vs_text": { "type": "string", @@ -39,7 +46,16 @@ "numeric_day_first", "weekday" ], - "default": "abbrev" + "default": "abbrev", + "x-options": { + "labels": { + "abbrev": "Sep 19", + "numeric": "9/19", + "day_first": "19 Sep", + "numeric_day_first": "19/9", + "weekday": "Fri Sep 19" + } + } }, "time_format": { "type": "string", @@ -49,7 +65,13 @@ "12h", "24h" ], - "default": "12h" + "default": "12h", + "x-options": { + "labels": { + "12h": "7:00PM (12-hour)", + "24h": "19:00 (24-hour)" + } + } }, "show_date": { "type": "boolean", From 95efca565fb070905c47d2dccf6ff6d5542c48a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 22:01:22 -0400 Subject: [PATCH 9/9] fix(sports): stop swallowing timezone errors silently The timezone lookup used try/except Exception: pass, which Codacy flags as B110 and which is genuinely worse than it looks: any failure inside ZoneInfo() vanished with no record, so a typo in the configured zone looked identical to no zone being set. Now catches the exceptions ZoneInfo actually raises -- KeyError (which covers ZoneInfoNotFoundError), ValueError, TypeError and OSError -- and logs the unusable value at debug before falling back to UTC. The fallback behaviour is unchanged; it just leaves a trace now. Verified: a valid zone resolves, and a bad one, an empty string and None all fall back to UTC without raising. Bandit reports no findings from this PR's changes (the three remaining B110 hits predate it), and the PR now introduces no broad `except Exception` at all. Harness 168/168, football adaptive 27/27. --- plugins/afl-scoreboard/game_renderer.py | 12 ++++++---- plugins/baseball-scoreboard/game_renderer.py | 12 ++++++---- .../basketball-scoreboard/game_renderer.py | 12 ++++++---- plugins/football-scoreboard/game_renderer.py | 12 ++++++---- plugins/hockey-scoreboard/game_renderer.py | 24 +++++++++++-------- plugins/lacrosse-scoreboard/game_renderer.py | 12 ++++++---- plugins/nrl-scoreboard/game_renderer.py | 12 ++++++---- plugins/soccer-scoreboard/game_renderer.py | 12 ++++++---- 8 files changed, 63 insertions(+), 45 deletions(-) diff --git a/plugins/afl-scoreboard/game_renderer.py b/plugins/afl-scoreboard/game_renderer.py index 84ffc7f9..12a231a4 100644 --- a/plugins/afl-scoreboard/game_renderer.py +++ b/plugins/afl-scoreboard/game_renderer.py @@ -737,12 +737,14 @@ def _weekday_for(self, game: Optional[Dict]) -> str: def _card_tzinfo(self): """Timezone for weekday/24h conversions; falls back to UTC.""" - try: - configured = (self.config or {}).get("timezone") - if configured: + configured = (self.config or {}).get("timezone") + if configured: + try: return ZoneInfo(configured) - except Exception: - pass + except (KeyError, ValueError, TypeError, OSError) as exc: + # KeyError covers ZoneInfoNotFoundError. A bad zone name in + # config should fall back to UTC, not blank the card. + self.logger.debug("Unusable timezone %r: %s", configured, exc) return timezone.utc def _format_game_time(self, time_text: str) -> str: diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index 39f2230e..99071a82 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -734,12 +734,14 @@ def _weekday_for(self, game: Optional[Dict]) -> str: def _card_tzinfo(self): """Timezone for weekday/24h conversions; falls back to UTC.""" - try: - configured = (self.config or {}).get("timezone") - if configured: + configured = (self.config or {}).get("timezone") + if configured: + try: return ZoneInfo(configured) - except Exception: - pass + except (KeyError, ValueError, TypeError, OSError) as exc: + # KeyError covers ZoneInfoNotFoundError. A bad zone name in + # config should fall back to UTC, not blank the card. + self.logger.debug("Unusable timezone %r: %s", configured, exc) return timezone.utc def _format_game_time(self, time_text: str) -> str: diff --git a/plugins/basketball-scoreboard/game_renderer.py b/plugins/basketball-scoreboard/game_renderer.py index 0d121da2..f363f26d 100644 --- a/plugins/basketball-scoreboard/game_renderer.py +++ b/plugins/basketball-scoreboard/game_renderer.py @@ -722,12 +722,14 @@ def _weekday_for(self, game: Optional[Dict]) -> str: def _card_tzinfo(self): """Timezone for weekday/24h conversions; falls back to UTC.""" - try: - configured = (self.config or {}).get("timezone") - if configured: + configured = (self.config or {}).get("timezone") + if configured: + try: return ZoneInfo(configured) - except Exception: - pass + except (KeyError, ValueError, TypeError, OSError) as exc: + # KeyError covers ZoneInfoNotFoundError. A bad zone name in + # config should fall back to UTC, not blank the card. + self.logger.debug("Unusable timezone %r: %s", configured, exc) return timezone.utc def _format_game_time(self, time_text: str) -> str: diff --git a/plugins/football-scoreboard/game_renderer.py b/plugins/football-scoreboard/game_renderer.py index f6f78053..d4a9acd3 100644 --- a/plugins/football-scoreboard/game_renderer.py +++ b/plugins/football-scoreboard/game_renderer.py @@ -1201,12 +1201,14 @@ def _weekday_for(self, game: Optional[Dict]) -> str: def _card_tzinfo(self): """Timezone for weekday/24h conversions; falls back to UTC.""" - try: - configured = (self.config or {}).get("timezone") - if configured: + configured = (self.config or {}).get("timezone") + if configured: + try: return ZoneInfo(configured) - except Exception: - pass + except (KeyError, ValueError, TypeError, OSError) as exc: + # KeyError covers ZoneInfoNotFoundError. A bad zone name in + # config should fall back to UTC, not blank the card. + self.logger.debug("Unusable timezone %r: %s", configured, exc) return timezone.utc def _format_game_time(self, time_text: str) -> str: diff --git a/plugins/hockey-scoreboard/game_renderer.py b/plugins/hockey-scoreboard/game_renderer.py index 0c9d3298..eef352fc 100644 --- a/plugins/hockey-scoreboard/game_renderer.py +++ b/plugins/hockey-scoreboard/game_renderer.py @@ -815,12 +815,14 @@ def _weekday_for(self, game: Optional[Dict]) -> str: def _card_tzinfo(self): """Timezone for weekday/24h conversions; falls back to UTC.""" - try: - configured = (self.config or {}).get("timezone") - if configured: + configured = (self.config or {}).get("timezone") + if configured: + try: return ZoneInfo(configured) - except Exception: - pass + except (KeyError, ValueError, TypeError, OSError) as exc: + # KeyError covers ZoneInfoNotFoundError. A bad zone name in + # config should fall back to UTC, not blank the card. + self.logger.debug("Unusable timezone %r: %s", configured, exc) return timezone.utc def _format_game_time(self, time_text: str) -> str: @@ -902,12 +904,14 @@ def _compact_time(text: str) -> str: def _display_tzinfo(self): """Timezone for rendering raw start times; falls back to UTC.""" - try: - configured = (self.config or {}).get("timezone") - if configured: + configured = (self.config or {}).get("timezone") + if configured: + try: return ZoneInfo(configured) - except Exception: - pass + except (KeyError, ValueError, TypeError, OSError) as exc: + # KeyError covers ZoneInfoNotFoundError. A bad zone name in + # config should fall back to UTC, not blank the card. + self.logger.debug("Unusable timezone %r: %s", configured, exc) return timezone.utc def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: diff --git a/plugins/lacrosse-scoreboard/game_renderer.py b/plugins/lacrosse-scoreboard/game_renderer.py index 09276860..5b2988ad 100644 --- a/plugins/lacrosse-scoreboard/game_renderer.py +++ b/plugins/lacrosse-scoreboard/game_renderer.py @@ -771,12 +771,14 @@ def _weekday_for(self, game: Optional[Dict]) -> str: def _card_tzinfo(self): """Timezone for weekday/24h conversions; falls back to UTC.""" - try: - configured = (self.config or {}).get("timezone") - if configured: + configured = (self.config or {}).get("timezone") + if configured: + try: return ZoneInfo(configured) - except Exception: - pass + except (KeyError, ValueError, TypeError, OSError) as exc: + # KeyError covers ZoneInfoNotFoundError. A bad zone name in + # config should fall back to UTC, not blank the card. + self.logger.debug("Unusable timezone %r: %s", configured, exc) return timezone.utc def _format_game_time(self, time_text: str) -> str: diff --git a/plugins/nrl-scoreboard/game_renderer.py b/plugins/nrl-scoreboard/game_renderer.py index 146dab2c..a99ff321 100644 --- a/plugins/nrl-scoreboard/game_renderer.py +++ b/plugins/nrl-scoreboard/game_renderer.py @@ -712,12 +712,14 @@ def _weekday_for(self, game: Optional[Dict]) -> str: def _card_tzinfo(self): """Timezone for weekday/24h conversions; falls back to UTC.""" - try: - configured = (self.config or {}).get("timezone") - if configured: + configured = (self.config or {}).get("timezone") + if configured: + try: return ZoneInfo(configured) - except Exception: - pass + except (KeyError, ValueError, TypeError, OSError) as exc: + # KeyError covers ZoneInfoNotFoundError. A bad zone name in + # config should fall back to UTC, not blank the card. + self.logger.debug("Unusable timezone %r: %s", configured, exc) return timezone.utc def _format_game_time(self, time_text: str) -> str: diff --git a/plugins/soccer-scoreboard/game_renderer.py b/plugins/soccer-scoreboard/game_renderer.py index 98036276..37b20b1a 100644 --- a/plugins/soccer-scoreboard/game_renderer.py +++ b/plugins/soccer-scoreboard/game_renderer.py @@ -712,12 +712,14 @@ def _weekday_for(self, game: Optional[Dict]) -> str: def _card_tzinfo(self): """Timezone for weekday/24h conversions; falls back to UTC.""" - try: - configured = (self.config or {}).get("timezone") - if configured: + configured = (self.config or {}).get("timezone") + if configured: + try: return ZoneInfo(configured) - except Exception: - pass + except (KeyError, ValueError, TypeError, OSError) as exc: + # KeyError covers ZoneInfoNotFoundError. A bad zone name in + # config should fall back to UTC, not blank the card. + self.logger.debug("Unusable timezone %r: %s", configured, exc) return timezone.utc def _format_game_time(self, time_text: str) -> str: