diff --git a/plugins.json b/plugins.json index 92caae7e..ece27ae6 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", @@ -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", @@ -335,7 +335,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.8.0", + "latest_version": "1.9.0", "icon": "fas fa-hockey-puck" }, { @@ -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..b9338238 100644 --- a/plugins/afl-scoreboard/config_schema.json +++ b/plugins/afl-scoreboard/config_schema.json @@ -4,6 +4,129 @@ "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. Upcoming games never show a score, since the game has not been played.", + "enum": [ + "vs", + "date_time", + "none" + ], + "default": "vs", + "x-options": { + "labels": { + "vs": "Matchup separator (VS / @ / at)", + "date_time": "Date and time, stacked", + "none": "Nothing" + } + } + }, + "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: abbrev \"Sep 19\", numeric \"9/19\", day_first \"19 Sep\", numeric_day_first \"19/9\", weekday \"Fri Sep 19\".", + "enum": [ + "abbrev", + "numeric", + "day_first", + "numeric_day_first", + "weekday" + ], + "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", + "title": "Time Format", + "description": "12h shows \"7:00PM\"; 24h shows \"19:00\".", + "enum": [ + "12h", + "24h" + ], + "default": "12h", + "x-options": { + "labels": { + "12h": "7:00PM (12-hour)", + "24h": "19:00 (24-hour)" + } + } + }, + "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 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, @@ -557,11 +680,31 @@ "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": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -593,11 +736,31 @@ "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": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -629,11 +792,31 @@ "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": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -665,11 +848,31 @@ "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": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -701,11 +904,31 @@ "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": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -737,11 +960,31 @@ "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": [ "font", - "font_size" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -924,7 +1167,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [0, 255, 0], + "default": [ + 0, + 255, + 0 + ], "x-advanced": true }, "loss_color": { @@ -939,7 +1186,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 0, 0], + "default": [ + 255, + 0, + 0 + ], "x-advanced": true }, "tie_color": { @@ -954,7 +1205,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 a7c2ca6a..12a231a4 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,25 +495,35 @@ 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) - away_x = (logo_slot - away_logo.width) // 2 - away_y = center_y - (away_logo.height // 2) + logo_slot = self._logo_slot_width() + 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) 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 + + 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) + ) + elif game_type == "upcoming": + self._draw_upcoming_center(draw_overlay, game) # Draw period/status based on game type if game_type == "live": @@ -581,24 +591,285 @@ 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"], 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. + # ------------------------------------------------------------------ + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + 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. + + 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 _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`` 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) + 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. + + 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: + """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", "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, 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: + return "" + fmt = str(self._scroll_card_option("date_format", "abbrev") or "abbrev") + if fmt == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + 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.""" + configured = (self.config or {}).get("timezone") + if configured: + try: + return ZoneInfo(configured) + 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: + """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 the VS text (default), the date and time stacked, + or nothing at all. + """ + 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 + 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'], + fill=self._element_color('score_text') + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + 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, (x, top + i * line_h), font, + fill=self._element_color('detail_text') + ) + + 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']) - 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']) - 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']) - + """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 _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..27102d00 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. 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" + }, { "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..cc7b6e4c 100644 --- a/plugins/afl-scoreboard/scroll_display.py +++ b/plugins/afl-scoreboard/scroll_display.py @@ -209,8 +209,11 @@ 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) + # 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") @@ -272,7 +275,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 +300,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..01fe7629 100644 --- a/plugins/afl-scoreboard/scroll_display_legacy.py +++ b/plugins/afl-scoreboard/scroll_display_legacy.py @@ -299,8 +299,11 @@ 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) + # 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") @@ -362,7 +365,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 +390,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..74d0029c 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -1,2149 +1,2530 @@ { - "$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", + "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. Upcoming games never show a score, since the game has not been played.", + "enum": [ + "vs", + "date_time", + "none" + ], + "default": "vs", + "x-options": { + "labels": { + "vs": "Matchup separator (VS / @ / at)", + "date_time": "Date and time, stacked", + "none": "Nothing" + } + } + }, + "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: abbrev \"Sep 19\", numeric \"9/19\", day_first \"19 Sep\", numeric_day_first \"19/9\", weekday \"Fri Sep 19\".", + "enum": [ + "abbrev", + "numeric", + "day_first", + "numeric_day_first", + "weekday" + ], + "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", + "title": "Time Format", + "description": "12h shows \"7:00PM\"; 24h shows \"19:00\".", + "enum": [ + "12h", + "24h" + ], + "default": "12h", + "x-options": { + "labels": { + "12h": "7:00PM (12-hour)", + "24h": "19:00 (24-hour)" + } + } + }, + "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 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" - } - } - }, - "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" - } + "max_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for MLB live games" } + } }, - "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." - } - } - }, - "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." - } + "max_duration_seconds": { + "x-advanced": true, + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for MiLB live games" } + } }, - "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" } + } }, - "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" - } + "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." + } + } + }, + "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)" + }, + "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" }, - "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 + "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 69120717..99071a82 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,10 +401,12 @@ 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) - away_x = (logo_slot - away_logo.width) // 2 + logo_slot = self._logo_slot_width() + 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: @@ -554,10 +557,12 @@ 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) - away_x = (logo_slot - away_logo.width) // 2 + logo_slot = self._logo_slot_width() + 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'], @@ -590,6 +596,277 @@ 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"], 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. + # ------------------------------------------------------------------ + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + 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.""" + 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 _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`` 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) + 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. + + 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: + """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", "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, 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: + return "" + fmt = str(self._scroll_card_option("date_format", "abbrev") or "abbrev") + if fmt == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + 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.""" + configured = (self.config or {}).get("timezone") + if configured: + try: + return ZoneInfo(configured) + 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: + """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 the VS text (default), the date and time stacked, + or nothing at all. + """ + 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 + 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'], + fill=self._element_color('score_text') + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + 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, (x, top + i * line_h), font, + fill=self._element_color('detail_text') + ) + + 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 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: @@ -607,18 +884,14 @@ 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) - away_x = (logo_slot - away_logo.width) // 2 + logo_slot = self._logo_slot_width() + 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) - # "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 +901,19 @@ 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) + # 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 b7957532..f60e13e2 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. 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" + }, { "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..460d6f3d 100644 --- a/plugins/baseball-scoreboard/scroll_display.py +++ b/plugins/baseball-scoreboard/scroll_display.py @@ -202,8 +202,11 @@ 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) + # 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()) @@ -245,7 +248,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 +273,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..7dc7e53f 100644 --- a/plugins/baseball-scoreboard/scroll_display_legacy.py +++ b/plugins/baseball-scoreboard/scroll_display_legacy.py @@ -356,8 +356,11 @@ 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) + # 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()) @@ -399,7 +402,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 +427,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..7f056547 100644 --- a/plugins/basketball-scoreboard/config_schema.json +++ b/plugins/basketball-scoreboard/config_schema.json @@ -4,6 +4,129 @@ "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. Upcoming games never show a score, since the game has not been played.", + "enum": [ + "vs", + "date_time", + "none" + ], + "default": "vs", + "x-options": { + "labels": { + "vs": "Matchup separator (VS / @ / at)", + "date_time": "Date and time, stacked", + "none": "Nothing" + } + } + }, + "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: abbrev \"Sep 19\", numeric \"9/19\", day_first \"19 Sep\", numeric_day_first \"19/9\", weekday \"Fri Sep 19\".", + "enum": [ + "abbrev", + "numeric", + "day_first", + "numeric_day_first", + "weekday" + ], + "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", + "title": "Time Format", + "description": "12h shows \"7:00PM\"; 24h shows \"19:00\".", + "enum": [ + "12h", + "24h" + ], + "default": "12h", + "x-options": { + "labels": { + "12h": "7:00PM (12-hour)", + "24h": "19:00 (24-hour)" + } + } + }, + "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 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, @@ -119,21 +242,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" } @@ -351,7 +483,7 @@ "type": "number", "minimum": 60, "maximum": 600, - "description": "Max duration for NBA games" + "description": "Max duration for NBA games" }, "modes": { "type": "object", @@ -419,11 +551,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, @@ -431,7 +566,10 @@ }, "upcoming_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -491,21 +629,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" } @@ -791,11 +938,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, @@ -803,7 +953,10 @@ }, "upcoming_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -863,21 +1016,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" } @@ -1201,11 +1363,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, @@ -1213,7 +1378,10 @@ }, "upcoming_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -1273,21 +1441,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" } @@ -1611,11 +1788,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, @@ -1623,7 +1803,10 @@ }, "upcoming_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -1634,422 +1817,591 @@ } }, "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" - }, - "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 - }, - "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 - } - }, - "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" - }, - "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 - }, - "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 - } - }, - "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" - }, - "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 - }, - "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 - } - }, - "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": "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", - "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 + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "away_logo": { - "type": "object", - "title": "Away Team Logo", - "properties": { - "x_offset": { - "x-advanced": true, + "x-propertyOrder": [ + "font", + "font_size", + "text_color" + ], + "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" + }, + "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", - "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 + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "score": { - "type": "object", - "title": "Game Score", - "properties": { - "x_offset": { - "x-advanced": true, + "x-propertyOrder": [ + "font", + "font_size", + "text_color" + ], + "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" + }, + "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", - "default": 0, - "description": "Horizontal offset from center (default: 0)" + "minimum": 0, + "maximum": 255 }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from center (default: 0)" - } - }, - "additionalProperties": false + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "status": { - "type": "object", - "title": "Game Status", - "properties": { - "x_offset": { - "x-advanced": true, + "x-propertyOrder": [ + "font", + "font_size", + "text_color" + ], + "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" + }, + "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", - "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 + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "record": { - "type": "object", - "title": "Team Records", - "properties": { - "x_offset": { - "x-advanced": true, + "x-propertyOrder": [ + "font", + "font_size", + "text_color" + ], + "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" + }, + "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", - "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 + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "ranking": { - "type": "object", - "title": "Team Rankings", - "properties": { - "x_offset": { - "x-advanced": true, + "x-propertyOrder": [ + "font", + "font_size", + "text_color" + ], + "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" + }, + "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", - "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 + "minItems": 3, + "maxItems": 3, + "default": [ + 255, + 255, + 255 + ], + "x-advanced": true + } }, - "odds": { - "type": "object", - "title": "Betting Odds", - "properties": { - "x_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "x-propertyOrder": [ + "font", + "font_size", + "text_color" + ], + "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)" + } }, - "y_offset": { - "x-advanced": true, - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } + "additionalProperties": false }, - "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 + "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 }, - "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 + "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 }, - "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 + "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)" + } + }, + "additionalProperties": false }, - "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 - } + "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)" + } + }, + "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 + } + }, + "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 + }, + "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 + } }, "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 520aaa7c..f363f26d 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,28 +477,38 @@ 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) - away_x = (logo_slot - away_logo.width) // 2 - away_y = center_y - (away_logo.height // 2) + logo_slot = self._logo_slot_width() + 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) - 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 + + 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) + ) + 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 +576,285 @@ 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"], 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. + # ------------------------------------------------------------------ + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + 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. + + 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 _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`` 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) + 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. + + 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: + """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", "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, 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: + return "" + fmt = str(self._scroll_card_option("date_format", "abbrev") or "abbrev") + if fmt == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + 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.""" + configured = (self.config or {}).get("timezone") + if configured: + try: + return ZoneInfo(configured) + 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: + """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 the VS text (default), the date and time stacked, + or nothing at all. + """ + 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 + 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'], + fill=self._element_color('score_text') + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + 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, (x, top + i * line_h), font, + fill=self._element_color('detail_text') + ) + + 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']}" + """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: - 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) + 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) + ) - # Game date and time - use flat format from sports.py - game_date = game.get("game_date", "") - game_time = game.get("game_time", "") + 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) + ) - 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']) - - if game_time: - time_width = draw.textlength(game_time, 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']) - 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..24a29f93 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. 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" + }, { "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..9b3cc2ad 100644 --- a/plugins/basketball-scoreboard/scroll_display.py +++ b/plugins/basketball-scoreboard/scroll_display.py @@ -213,8 +213,11 @@ 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) + # 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") @@ -284,7 +287,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 +312,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..39296be8 100644 --- a/plugins/basketball-scoreboard/scroll_display_legacy.py +++ b/plugins/basketball-scoreboard/scroll_display_legacy.py @@ -352,8 +352,11 @@ 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) + # 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") @@ -423,7 +426,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 +451,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..1297fe21 100644 --- a/plugins/football-scoreboard/config_schema.json +++ b/plugins/football-scoreboard/config_schema.json @@ -4,6 +4,129 @@ "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. Upcoming games never show a score, since the game has not been played.", + "enum": [ + "vs", + "date_time", + "none" + ], + "default": "vs", + "x-options": { + "labels": { + "vs": "Matchup separator (VS / @ / at)", + "date_time": "Date and time, stacked", + "none": "Nothing" + } + } + }, + "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: abbrev \"Sep 19\", numeric \"9/19\", day_first \"19 Sep\", numeric_day_first \"19/9\", weekday \"Fri Sep 19\".", + "enum": [ + "abbrev", + "numeric", + "day_first", + "numeric_day_first", + "weekday" + ], + "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", + "title": "Time Format", + "description": "12h shows \"7:00PM\"; 24h shows \"19:00\".", + "enum": [ + "12h", + "24h" + ], + "default": "12h", + "x-options": { + "labels": { + "12h": "7:00PM (12-hour)", + "24h": "19:00 (24-hour)" + } + } + }, + "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 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, @@ -82,21 +205,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" } @@ -450,21 +582,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" } @@ -771,9 +912,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, @@ -782,503 +926,675 @@ "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", + "text_color" + ], + "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", + "text_color" + ], + "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", + "text_color" + ], + "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", + "text_color" + ], + "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", + "text_color" + ], + "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", + "text_color" + ], + "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 }, - "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 + }, + "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 1cf52a09..d4a9acd3 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,30 +559,40 @@ 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) - away_x = (logo_slot - away_logo.width) // 2 - away_y = center_y - (away_logo.height // 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 + + 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) - 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 + + 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) + ) + 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) @@ -790,13 +800,23 @@ 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_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) @@ -807,16 +827,32 @@ 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", ""), game), + regs, 'date') elif game_type == "upcoming": - 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') + 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: + 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'): @@ -1019,24 +1055,285 @@ 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"], 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. + # ------------------------------------------------------------------ + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + 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. + + 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 _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`` 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) + 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. + + 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: + """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", "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, 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: + return "" + fmt = str(self._scroll_card_option("date_format", "abbrev") or "abbrev") + if fmt == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + 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.""" + configured = (self.config or {}).get("timezone") + if configured: + try: + return ZoneInfo(configured) + 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: + """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 the VS text (default), the date and time stacked, + or nothing at all. + """ + 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 + 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'], + fill=self._element_color('score_text') + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + 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, (x, top + i * line_h), font, + fill=self._element_color('detail_text') + ) + + 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']) - 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']) - 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']) - + """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 _draw_possession_indicator( self, draw: ImageDraw.Draw, diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 4002dab8..4d5f9a0a 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. 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" + }, { "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..53177623 100644 --- a/plugins/football-scoreboard/scroll_display.py +++ b/plugins/football-scoreboard/scroll_display.py @@ -172,8 +172,11 @@ 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) + # 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") @@ -231,7 +234,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 +259,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..2af68e91 100644 --- a/plugins/football-scoreboard/scroll_display_legacy.py +++ b/plugins/football-scoreboard/scroll_display_legacy.py @@ -313,8 +313,11 @@ 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) + # 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") @@ -372,7 +375,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 +400,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/test/golden-adaptive/128x32/recent.png b/plugins/football-scoreboard/test/golden-adaptive/128x32/recent.png index 98caf058..cfee31f0 100644 Binary files a/plugins/football-scoreboard/test/golden-adaptive/128x32/recent.png and b/plugins/football-scoreboard/test/golden-adaptive/128x32/recent.png differ diff --git a/plugins/football-scoreboard/test/golden-adaptive/128x32/upcoming.png b/plugins/football-scoreboard/test/golden-adaptive/128x32/upcoming.png index 77987ed0..8c6b593b 100644 Binary files a/plugins/football-scoreboard/test/golden-adaptive/128x32/upcoming.png and b/plugins/football-scoreboard/test/golden-adaptive/128x32/upcoming.png differ diff --git a/plugins/football-scoreboard/test/golden-adaptive/128x64/recent.png b/plugins/football-scoreboard/test/golden-adaptive/128x64/recent.png index 54b4df6d..a880005d 100644 Binary files a/plugins/football-scoreboard/test/golden-adaptive/128x64/recent.png and b/plugins/football-scoreboard/test/golden-adaptive/128x64/recent.png differ diff --git a/plugins/football-scoreboard/test/golden-adaptive/128x64/upcoming.png b/plugins/football-scoreboard/test/golden-adaptive/128x64/upcoming.png index 57fc4d48..ca58f217 100644 Binary files a/plugins/football-scoreboard/test/golden-adaptive/128x64/upcoming.png and b/plugins/football-scoreboard/test/golden-adaptive/128x64/upcoming.png differ diff --git a/plugins/football-scoreboard/test/golden-adaptive/256x128/recent.png b/plugins/football-scoreboard/test/golden-adaptive/256x128/recent.png index 7f1f9578..64543d3d 100644 Binary files a/plugins/football-scoreboard/test/golden-adaptive/256x128/recent.png and b/plugins/football-scoreboard/test/golden-adaptive/256x128/recent.png differ diff --git a/plugins/football-scoreboard/test/golden-adaptive/256x128/upcoming.png b/plugins/football-scoreboard/test/golden-adaptive/256x128/upcoming.png index d4619482..ae8d55fc 100644 Binary files a/plugins/football-scoreboard/test/golden-adaptive/256x128/upcoming.png and b/plugins/football-scoreboard/test/golden-adaptive/256x128/upcoming.png differ diff --git a/plugins/hockey-scoreboard/config_schema.json b/plugins/hockey-scoreboard/config_schema.json index 4b2110f9..e6c6ca86 100644 --- a/plugins/hockey-scoreboard/config_schema.json +++ b/plugins/hockey-scoreboard/config_schema.json @@ -4,6 +4,129 @@ "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. Upcoming games never show a score, since the game has not been played.", + "enum": [ + "vs", + "date_time", + "none" + ], + "default": "vs", + "x-options": { + "labels": { + "vs": "Matchup separator (VS / @ / at)", + "date_time": "Date and time, stacked", + "none": "Nothing" + } + } + }, + "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: abbrev \"Sep 19\", numeric \"9/19\", day_first \"19 Sep\", numeric_day_first \"19/9\", weekday \"Fri Sep 19\".", + "enum": [ + "abbrev", + "numeric", + "day_first", + "numeric_day_first", + "weekday" + ], + "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", + "title": "Time Format", + "description": "12h shows \"7:00PM\"; 24h shows \"19:00\".", + "enum": [ + "12h", + "24h" + ], + "default": "12h", + "x-options": { + "labels": { + "12h": "7:00PM (12-hour)", + "24h": "19:00 (24-hour)" + } + } + }, + "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 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": false, @@ -93,7 +216,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" }, @@ -105,7 +231,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" }, @@ -117,7 +246,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" } @@ -180,14 +312,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." }, @@ -364,11 +500,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, @@ -376,7 +515,10 @@ }, "upcoming_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -384,7 +526,10 @@ }, "live_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -500,7 +645,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" }, @@ -512,7 +660,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" }, @@ -524,7 +675,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" } @@ -587,14 +741,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." }, @@ -771,11 +929,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, @@ -783,7 +944,10 @@ }, "upcoming_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -791,7 +955,10 @@ }, "live_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -907,7 +1074,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" }, @@ -919,7 +1089,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" }, @@ -931,7 +1104,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" } @@ -994,14 +1170,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." }, @@ -1178,11 +1358,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, @@ -1190,7 +1373,10 @@ }, "upcoming_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -1198,7 +1384,10 @@ }, "live_mode_duration": { "x-advanced": true, - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "default": null, "minimum": 10, "maximum": 600, @@ -1322,9 +1511,32 @@ "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", + "text_color" + ], "additionalProperties": false }, "period_text": { @@ -1352,9 +1564,32 @@ "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", + "text_color" + ], "additionalProperties": false }, "team_name": { @@ -1382,9 +1617,32 @@ "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", + "text_color" + ], "additionalProperties": false }, "status_text": { @@ -1412,9 +1670,32 @@ "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", + "text_color" + ], "additionalProperties": false }, "detail_text": { @@ -1442,9 +1723,32 @@ "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", + "text_color" + ], "additionalProperties": false }, "rank_text": { @@ -1472,9 +1776,32 @@ "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", + "text_color" + ], "additionalProperties": false }, "layout": { @@ -1622,7 +1949,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": { @@ -1648,7 +1983,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [0, 255, 0], + "default": [ + 0, + 255, + 0 + ], "x-advanced": true }, "loss_color": { @@ -1663,7 +2002,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 0, 0], + "default": [ + 255, + 0, + 0 + ], "x-advanced": true }, "tie_color": { @@ -1678,7 +2021,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 200, 0], + "default": [ + 255, + 200, + 0 + ], "x-advanced": true } }, @@ -1691,9 +2038,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 a93d85b7..eef352fc 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__) @@ -184,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): @@ -209,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 @@ -306,12 +311,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 @@ -487,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: @@ -504,16 +538,18 @@ 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) - away_x = (logo_slot - away_logo.width) // 2 - away_y = center_y - (away_logo.height // 2) + # Draw logos — each centered within its slot on its side. + 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 @@ -522,19 +558,16 @@ 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) ) 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": @@ -606,36 +639,332 @@ 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 "", "" + + # ------------------------------------------------------------------ + # 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. + # ------------------------------------------------------------------ + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + 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.""" + 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 _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`` 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) + 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. + + 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: + """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", "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, 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: + return "" + fmt = str(self._scroll_card_option("date_format", "abbrev") or "abbrev") + if fmt == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + 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.""" + configured = (self.config or {}).get("timezone") + if configured: + try: + return ZoneInfo(configured) + 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: + """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 the VS text (default), the date and time stacked, + or nothing at all. + """ + 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 + 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'], + fill=self._element_color('score_text') + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + 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, (x, top + i * line_h), font, + fill=self._element_color('detail_text') + ) + + @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.""" + configured = (self.config or {}).get("timezone") + if configured: + try: + return ZoneInfo(configured) + 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 - 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 the date and time around an upcoming card. - game_date = local_dt.strftime("%b %d") + 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) + ) - 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 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 _draw_records_or_rankings(self, draw: ImageDraw.Draw, game: Dict) -> None: """Draw team records or rankings.""" 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 a91f069d..2b25c7d7 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.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", @@ -54,6 +54,12 @@ } ], "versions": [ + { + "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. 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" + }, { "version": "1.8.0", "released": "2026-08-05", diff --git a/plugins/hockey-scoreboard/scroll_display.py b/plugins/hockey-scoreboard/scroll_display.py index 5a16a58b..58e0ba8f 100644 --- a/plugins/hockey-scoreboard/scroll_display.py +++ b/plugins/hockey-scoreboard/scroll_display.py @@ -205,8 +205,13 @@ 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) + # 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 @@ -237,10 +242,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: @@ -248,10 +253,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") @@ -266,9 +271,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 +301,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 ) diff --git a/plugins/hockey-scoreboard/scroll_display_legacy.py b/plugins/hockey-scoreboard/scroll_display_legacy.py index 5aaf25ab..4babcea1 100644 --- a/plugins/hockey-scoreboard/scroll_display_legacy.py +++ b/plugins/hockey-scoreboard/scroll_display_legacy.py @@ -336,8 +336,11 @@ 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) + # 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 @@ -368,10 +371,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: @@ -379,10 +382,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") @@ -399,7 +402,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 +427,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..d5c6d783 100644 --- a/plugins/lacrosse-scoreboard/config_schema.json +++ b/plugins/lacrosse-scoreboard/config_schema.json @@ -4,6 +4,129 @@ "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. Upcoming games never show a score, since the game has not been played.", + "enum": [ + "vs", + "date_time", + "none" + ], + "default": "vs", + "x-options": { + "labels": { + "vs": "Matchup separator (VS / @ / at)", + "date_time": "Date and time, stacked", + "none": "Nothing" + } + } + }, + "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: abbrev \"Sep 19\", numeric \"9/19\", day_first \"19 Sep\", numeric_day_first \"19/9\", weekday \"Fri Sep 19\".", + "enum": [ + "abbrev", + "numeric", + "day_first", + "numeric_day_first", + "weekday" + ], + "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", + "title": "Time Format", + "description": "12h shows \"7:00PM\"; 24h shows \"19:00\".", + "enum": [ + "12h", + "24h" + ], + "default": "12h", + "x-options": { + "labels": { + "12h": "7:00PM (12-hour)", + "24h": "19:00 (24-hour)" + } + } + }, + "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 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": false, @@ -931,11 +1054,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -964,11 +1107,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -997,11 +1160,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1030,11 +1213,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1063,11 +1266,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1096,11 +1319,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -1283,7 +1526,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [0, 255, 0], + "default": [ + 0, + 255, + 0 + ], "x-advanced": true }, "loss_color": { @@ -1298,7 +1545,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 0, 0], + "default": [ + 255, + 0, + 0 + ], "x-advanced": true }, "tie_color": { @@ -1313,7 +1564,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 9b66b7ad..5b2988ad 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,14 +515,18 @@ 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) - away_x = (logo_slot - away_logo.width) // 2 - away_y = center_y - (away_logo.height // 2) + logo_slot = self._logo_slot_width() + 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) @@ -619,32 +625,284 @@ 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"], 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. + # ------------------------------------------------------------------ + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + 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. + + 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 _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 - Matches the direct display path: "Next Game" label at top, then stacked - date and time centered on the card — no "VS" text. + 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`` pins it outright; otherwise it scales with + the card width between the configurable min and max. 0 restores + edge-to-edge logos. """ - game_date = game.get("game_date", "") - game_time = game.get("game_time", "") + configured = self._scroll_card_option("center_gap") + if isinstance(configured, (int, float)) and configured >= 0: + return int(configured) + 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 - # "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 _logo_slot_width(self) -> int: + """Per-side logo slot, leaving the center gap clear. - # Stacked date / time centered vertically - center_y = self.display_height // 2 - date_y = center_y - 7 + 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: + """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", "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, 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: + return "" + fmt = str(self._scroll_card_option("date_format", "abbrev") or "abbrev") + if fmt == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + 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.""" + configured = (self.config or {}).get("timezone") + if configured: + try: + return ZoneInfo(configured) + 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: + """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 the VS text (default), the date and time stacked, + or nothing at all. + """ + 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 + 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'], + fill=self._element_color('score_text') + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + 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, (x, top + i * line_h), font, + fill=self._element_color('detail_text') + ) + + 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 ""), + ) - 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']) + def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: + """Draw the date and time around an upcoming card. - 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']) + 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 _draw_dynamic_odds( self, diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index 76260a2b..5ee7d8f9 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. 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" + }, { "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..1277f531 100644 --- a/plugins/lacrosse-scoreboard/scroll_display.py +++ b/plugins/lacrosse-scoreboard/scroll_display.py @@ -192,8 +192,11 @@ 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) + # 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 @@ -224,10 +227,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: @@ -235,10 +238,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") @@ -255,7 +258,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 +283,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..89119646 100644 --- a/plugins/lacrosse-scoreboard/scroll_display_legacy.py +++ b/plugins/lacrosse-scoreboard/scroll_display_legacy.py @@ -317,8 +317,11 @@ 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) + # 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 @@ -349,10 +352,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: @@ -360,10 +363,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") @@ -380,7 +383,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 +408,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..f68ad7d1 100644 --- a/plugins/nrl-scoreboard/config_schema.json +++ b/plugins/nrl-scoreboard/config_schema.json @@ -4,6 +4,129 @@ "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. Upcoming games never show a score, since the game has not been played.", + "enum": [ + "vs", + "date_time", + "none" + ], + "default": "vs", + "x-options": { + "labels": { + "vs": "Matchup separator (VS / @ / at)", + "date_time": "Date and time, stacked", + "none": "Nothing" + } + } + }, + "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: abbrev \"Sep 19\", numeric \"9/19\", day_first \"19 Sep\", numeric_day_first \"19/9\", weekday \"Fri Sep 19\".", + "enum": [ + "abbrev", + "numeric", + "day_first", + "numeric_day_first", + "weekday" + ], + "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", + "title": "Time Format", + "description": "12h shows \"7:00PM\"; 24h shows \"19:00\".", + "enum": [ + "12h", + "24h" + ], + "default": "12h", + "x-options": { + "labels": { + "12h": "7:00PM (12-hour)", + "24h": "19:00 (24-hour)" + } + } + }, + "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 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, @@ -551,11 +674,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -585,11 +728,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -619,11 +782,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -653,11 +836,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -687,11 +890,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -721,11 +944,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -893,7 +1136,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [0, 255, 0], + "default": [ + 0, + 255, + 0 + ], "x-advanced": true }, "loss_color": { @@ -908,7 +1155,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 0, 0], + "default": [ + 255, + 0, + 0 + ], "x-advanced": true }, "tie_color": { @@ -923,7 +1174,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 200, 0], + "default": [ + 255, + 200, + 0 + ], "x-advanced": true } }, @@ -948,7 +1203,7 @@ ], "additionalProperties": false } -}, + }, "additionalProperties": false, "required": [ "enabled" @@ -988,4 +1243,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 0bb85d9a..a99ff321 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,25 +470,35 @@ 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) - away_x = (logo_slot - away_logo.width) // 2 - away_y = center_y - (away_logo.height // 2) + logo_slot = self._logo_slot_width() + 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) 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 + + 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) + ) + elif game_type == "upcoming": + self._draw_upcoming_center(draw_overlay, game) # Draw period/status based on game type if game_type == "live": @@ -556,24 +566,285 @@ 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"], 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. + # ------------------------------------------------------------------ + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + 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. + + 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 _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`` 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) + 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. + + 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: + """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", "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, 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: + return "" + fmt = str(self._scroll_card_option("date_format", "abbrev") or "abbrev") + if fmt == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + 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.""" + configured = (self.config or {}).get("timezone") + if configured: + try: + return ZoneInfo(configured) + 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: + """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 the VS text (default), the date and time stacked, + or nothing at all. + """ + 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 + 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'], + fill=self._element_color('score_text') + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + 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, (x, top + i * line_h), font, + fill=self._element_color('detail_text') + ) + + 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']) - 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']) - 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']) - + """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 _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..5a30d84f 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. 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" + }, { "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..0514c6e1 100644 --- a/plugins/nrl-scoreboard/scroll_display.py +++ b/plugins/nrl-scoreboard/scroll_display.py @@ -255,8 +255,11 @@ 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) + # 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 @@ -286,9 +289,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: @@ -296,10 +299,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") @@ -314,7 +317,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 +342,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..3f0a7aea 100644 --- a/plugins/nrl-scoreboard/scroll_display_legacy.py +++ b/plugins/nrl-scoreboard/scroll_display_legacy.py @@ -342,8 +342,11 @@ 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) + # 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 @@ -373,9 +376,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: @@ -383,10 +386,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") @@ -401,7 +404,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 +429,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..9e8ba441 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -4,6 +4,129 @@ "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. Upcoming games never show a score, since the game has not been played.", + "enum": [ + "vs", + "date_time", + "none" + ], + "default": "vs", + "x-options": { + "labels": { + "vs": "Matchup separator (VS / @ / at)", + "date_time": "Date and time, stacked", + "none": "Nothing" + } + } + }, + "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: abbrev \"Sep 19\", numeric \"9/19\", day_first \"19 Sep\", numeric_day_first \"19/9\", weekday \"Fri Sep 19\".", + "enum": [ + "abbrev", + "numeric", + "day_first", + "numeric_day_first", + "weekday" + ], + "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", + "title": "Time Format", + "description": "12h shows \"7:00PM\"; 24h shows \"19:00\".", + "enum": [ + "12h", + "24h" + ], + "default": "12h", + "x-options": { + "labels": { + "12h": "7:00PM (12-hour)", + "24h": "19:00 (24-hour)" + } + } + }, + "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 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": false, @@ -518,7 +641,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": [ @@ -927,7 +1050,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": [ @@ -1336,7 +1459,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": [ @@ -1745,7 +1868,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": [ @@ -2154,7 +2277,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": [ @@ -2563,7 +2686,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": [ @@ -2972,7 +3095,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": [ @@ -3381,7 +3504,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": [ @@ -3790,7 +3913,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": [ @@ -4199,7 +4322,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": [ @@ -4247,7 +4370,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", @@ -4275,7 +4398,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, @@ -4287,7 +4413,11 @@ "description": "Whether this league is enabled" }, "favorite_teams": { - "type": ["array", "string", "null"], + "type": [ + "array", + "string", + "null" + ], "items": { "type": "string" }, @@ -4297,7 +4427,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" @@ -4368,7 +4502,10 @@ "additionalProperties": false }, "live_game_duration": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": 20, "minimum": 10, "maximum": 120, @@ -4376,7 +4513,10 @@ "x-advanced": true }, "non_favorite_live_game_duration": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": 0, "minimum": 0, "maximum": 120, @@ -4384,7 +4524,10 @@ "x-advanced": true }, "recent_game_duration": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": 15, "minimum": 5, "maximum": 60, @@ -4392,7 +4535,10 @@ "x-advanced": true }, "upcoming_game_duration": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": 15, "minimum": 5, "maximum": 60, @@ -4405,14 +4551,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, @@ -4437,7 +4589,10 @@ "description": "Show all live games, not just favorites" }, "favorite_live_boost": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "minimum": 1, "maximum": 5, "default": 2, @@ -4459,7 +4614,10 @@ "x-advanced": true }, "min_duration_seconds": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "minimum": 10, "maximum": 300, "default": 30, @@ -4467,14 +4625,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": { @@ -4488,13 +4652,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 @@ -4512,13 +4682,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 @@ -4536,13 +4712,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 @@ -4595,11 +4777,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -4629,11 +4831,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -4663,11 +4885,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -4697,11 +4939,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -4731,11 +4993,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -4765,11 +5047,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" + "font_size", + "text_color" ], "additionalProperties": false }, @@ -4937,7 +5239,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [0, 255, 0], + "default": [ + 0, + 255, + 0 + ], "x-advanced": true }, "loss_color": { @@ -4952,7 +5258,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 0, 0], + "default": [ + 255, + 0, + 0 + ], "x-advanced": true }, "tie_color": { @@ -4967,7 +5277,11 @@ }, "minItems": 3, "maxItems": 3, - "default": [255, 200, 0], + "default": [ + 255, + 200, + 0 + ], "x-advanced": true } }, @@ -4992,7 +5306,7 @@ ], "additionalProperties": false } -}, + }, "additionalProperties": false, "required": [ "enabled" diff --git a/plugins/soccer-scoreboard/game_renderer.py b/plugins/soccer-scoreboard/game_renderer.py index eebf4857..37b20b1a 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,25 +470,35 @@ 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) - away_x = (logo_slot - away_logo.width) // 2 - away_y = center_y - (away_logo.height // 2) + logo_slot = self._logo_slot_width() + 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) 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 + + 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) + ) + elif game_type == "upcoming": + self._draw_upcoming_center(draw_overlay, game) # Draw period/status based on game type if game_type == "live": @@ -556,24 +566,285 @@ 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"], 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. + # ------------------------------------------------------------------ + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + 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. + + 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 _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`` 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) + 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. + + 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: + """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", "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, 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: + return "" + fmt = str(self._scroll_card_option("date_format", "abbrev") or "abbrev") + if fmt == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + 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.""" + configured = (self.config or {}).get("timezone") + if configured: + try: + return ZoneInfo(configured) + 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: + """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 the VS text (default), the date and time stacked, + or nothing at all. + """ + 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 + 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'], + fill=self._element_color('score_text') + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + 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, (x, top + i * line_h), font, + fill=self._element_color('detail_text') + ) + + 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']) - 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']) - 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']) - + """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 _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..44708617 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. 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" + }, { "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..b38f72ba 100644 --- a/plugins/soccer-scoreboard/scroll_display.py +++ b/plugins/soccer-scoreboard/scroll_display.py @@ -260,8 +260,11 @@ 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) + # 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 @@ -291,9 +294,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: @@ -301,10 +304,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") @@ -319,7 +322,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 +347,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..ddfaca6f 100644 --- a/plugins/soccer-scoreboard/scroll_display_legacy.py +++ b/plugins/soccer-scoreboard/scroll_display_legacy.py @@ -342,8 +342,11 @@ 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) + # 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 @@ -373,9 +376,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: @@ -383,10 +386,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") @@ -401,7 +404,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 +429,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 )