diff --git a/plugins.json b/plugins.json index e08d8253..2db494f0 100644 --- a/plugins.json +++ b/plugins.json @@ -76,7 +76,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.22.2" + "latest_version": "1.23.0" }, { "id": "basketball-scoreboard", @@ -101,7 +101,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.10.3" + "latest_version": "1.11.0" }, { "id": "calendar", @@ -240,7 +240,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.11.2" + "latest_version": "2.12.0" }, { "id": "geochron", @@ -335,7 +335,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.7.3", + "latest_version": "1.8.0", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.7.3", + "latest_version": "1.8.0", "icon": "fas fa-baseball-ball" }, { @@ -760,7 +760,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.6.2" + "latest_version": "2.7.0" }, { "id": "static-image", @@ -1048,7 +1048,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.3.2", + "latest_version": "1.4.0", "last_updated": "2026-08-05" }, { @@ -1095,7 +1095,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.3.2" + "latest_version": "1.4.0" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/README.md b/plugins/afl-scoreboard/README.md index 5f398399..3743e823 100644 --- a/plugins/afl-scoreboard/README.md +++ b/plugins/afl-scoreboard/README.md @@ -25,6 +25,7 @@ Football League)** games with real-time scores and game status. - **Switch or Scroll**: Show one game at a time, or scroll all games horizontally - **Dynamic Duration & Live Priority**: Spend more time on live games; let live games interrupt the rotation - **Background Data Fetching**: Efficient API calls without blocking the display +- **Favorite Team Result Colors**: Optionally show a finished game's score in green when your favorite team won and red when it lost ## Display Modes @@ -149,6 +150,37 @@ Manual install: copy this directory into your LEDMatrix `plugins_directory` upcoming game) for the core plugin safety harness (`LEDMatrix/scripts/check_plugin.py`). +## Favorite Team Result Colors + +A run of games against the same opponent is hard to read at a glance: in scroll +and Vegas mode the same two logos go past several times and only the digits +change. Turn on **Customization -> Favorite Team Result Colors** to color a +finished game's score by how your favorite team did - green for a win, red for +a loss. + +```json +{ + "customization": { + "favorite_result_colors": { + "enabled": true, + "win_color": [0, 255, 0], + "loss_color": [255, 0, 0], + "tie_color": [255, 200, 0] + } + } +} +``` + +- Off by default. Until you enable it the score keeps exactly the color it has + today. +- Only finished games are colored. Live and upcoming cards are untouched. +- A game needs exactly one favorite team. If neither side is a favorite, or both + are, the score keeps its normal color. +- Applies to both the one-game-at-a-time switch view and the scroll/Vegas + ticker. +- The three colors are Advanced settings; leave them alone for the defaults + above. + ## Troubleshooting - **Start times look like UTC** (a 6:45pm Central start showing as 11:45PM): diff --git a/plugins/afl-scoreboard/config_schema.json b/plugins/afl-scoreboard/config_schema.json index 02e0ccc8..a053e8cd 100644 --- a/plugins/afl-scoreboard/config_schema.json +++ b/plugins/afl-scoreboard/config_schema.json @@ -900,6 +900,71 @@ "records" ], "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": [ @@ -909,7 +974,8 @@ "status_text", "detail_text", "rank_text", - "layout" + "layout", + "favorite_result_colors" ], "additionalProperties": false } diff --git a/plugins/afl-scoreboard/game_renderer.py b/plugins/afl-scoreboard/game_renderer.py index 76eb7ad1..a7c2ca6a 100644 --- a/plugins/afl-scoreboard/game_renderer.py +++ b/plugins/afl-scoreboard/game_renderer.py @@ -15,7 +15,7 @@ import os import sys from pathlib import Path -from typing import Dict, Any, Optional, Tuple +from typing import Any, ClassVar, Dict, Optional, Tuple from PIL import Image, ImageDraw, ImageFont # Add project root to path to import the shared logo downloader (same @@ -303,6 +303,143 @@ def _draw_text_with_outline( draw.text((x + dx, y + dy), text, font=font, fill=outline_color) draw.text((x, y), text, font=font, fill=fill) + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # This is the scroll/Vegas path, and it is where the setting earns its + # keep: a series against the same opponent scrolls past as several + # near-identical cards, so tinting the final score green or red is the + # only quick way to tell a win from a loss. Off by default -- the score + # keeps the color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + def _favorite_teams_for(self, game: Dict[str, Any]) -> list: + """Favorite teams that apply to this game. + + Both sources are used. Games carry the league manager's *resolved* + favorites, which is the only place dynamic groups such as AP_TOP_25 + appear expanded; the config is read as well so an edit takes effect on + already-fetched games, and so hand-built game dicts (tests, other + callers) still work. + """ + favorites = list(game.get("favorite_teams") or []) + league_config = self.config.get(str(game.get("league", "") or "")) + if isinstance(league_config, dict): + favorites += list(league_config.get("favorite_teams") or []) + else: + favorites += list(self.config.get("favorite_teams") or []) + return favorites + + @staticmethod + def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) + payload shapes, and matches on the ESPN id too, because a couple of + leagues (NRL) key favorites by id where abbreviations collide. + """ + candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] + team = game.get(f"{side}_team") + if isinstance(team, dict): + candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] + for value in candidates: + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + @staticmethod + def _side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """Numeric score for one side, from either payload shape.""" + raw = None + team = game.get(f"{side}_team") + if isinstance(team, dict) and team.get("score") is not None: + raw = team.get("score") + if raw is None: + raw = game.get(f"{side}_score") + try: + return int(float(str(raw).strip())) + except (TypeError, ValueError): + return None + + def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = { + str(team).strip().upper() + for team in self._favorite_teams_for(game) + if str(team).strip() + } + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + home_score = self._side_score(game, "home") + away_score = self._side_score(game, "away") + if home_score is None or away_score is None: + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _score_color_for(self, game: Dict[str, Any], game_type: str, default=(255, 255, 255)): + """Fill color for a game card's score. Only finished games are tinted.""" + if game_type != "recent": + return default + return self._recent_score_color(game, default) + + def _recent_score_color(self, game: Dict[str, Any], default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def render_game_card( self, game: Dict[str, Any], @@ -373,7 +510,10 @@ def render_game_card( # 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']) + 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 period/status based on game type if game_type == "live": diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index bc3132fe..2fde071a 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.3.2", + "version": "1.4.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.4.0", + "released": "2026-08-05", + "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.3.2", "released": "2026-08-05", diff --git a/plugins/afl-scoreboard/sports.py b/plugins/afl-scoreboard/sports.py index 95365559..08182b13 100644 --- a/plugins/afl-scoreboard/sports.py +++ b/plugins/afl-scoreboard/sports.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional, Tuple import pytz import requests @@ -476,6 +476,103 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: self.logger.debug(f"Error getting layout offset for {element}.{axis}: {e}, using default {default}") return default + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # In scroll and Vegas modes the same two logos cycle past over and over -- + # a four-game series against a division rival is four near-identical cards + # -- and picking out which side is yours from the digits alone is the whole + # problem. Tinting the final score by how the favorite did makes it + # readable at a glance. Off by default, so an existing install keeps the + # score color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + @staticmethod + def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Both the abbreviation and the ESPN id are checked, because a couple of + leagues (NRL) match favorites by id where abbreviations collide. + """ + for key in (f"{side}_abbr", f"{side}_id"): + value = game.get(key) + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + def _favorite_result(self, game: Dict) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = getattr(self, "favorite_teams", None) or [] + favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + try: + # int(float(...)) to match GameRenderer._side_score exactly -- the + # two paths must agree on what counts as a usable score. + home_score = int(float(str(game.get("home_score", "")).strip())) + away_score = int(float(str(game.get("away_score", "")).strip())) + except (TypeError, ValueError): + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _recent_score_color(self, game: Dict, default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -1066,6 +1163,11 @@ def extract_logo_url(team_data): / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), "away_logo_url": away_logo_url, "is_within_window": True, # Whether game is within display window + # The resolved favorites for this league (dynamic groups such + # as AP_TOP_25 already expanded). Carried on the game so the + # scroll/Vegas renderer, which only ever sees the game dict and + # the raw config, can color a final score by the result. + "favorite_teams": list(self.favorite_teams or []), } return details, home_team, away_team, status, situation except Exception as e: @@ -2099,7 +2201,11 @@ def format_score(score): # date fits on the bottom line without colliding with the score. score_y = (display_height // 2) - 3 + self._get_layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + draw_overlay, + score_text, + (score_x, score_y), + self.fonts["score"], + fill=self._recent_score_color(game, (255, 255, 255)), ) # "Final" text (Top center) with layout offsets diff --git a/plugins/baseball-scoreboard/README.md b/plugins/baseball-scoreboard/README.md index 1eb774ab..0d10e719 100644 --- a/plugins/baseball-scoreboard/README.md +++ b/plugins/baseball-scoreboard/README.md @@ -23,6 +23,7 @@ A plugin for LEDMatrix that displays live, recent, and upcoming baseball games a - **Upcoming Games**: Scheduled games with start times - **Favorite Teams**: Prioritize games involving your favorite teams - **Background Data Fetching**: Efficient API calls without blocking display +- **Favorite Team Result Colors**: Optionally show a finished game's score in green when your favorite team won and red when it lost ## Configuration @@ -411,6 +412,39 @@ Manual install: copy this directory into your LEDMatrix `plugins_directory` (default `plugin-repos/`) and restart the display service. +## Favorite Team Result Colors + +A run of games against the same opponent is hard to read at a glance: in scroll +and Vegas mode the same two logos go past several times and only the digits +change. Turn on **Customization -> Favorite Team Result Colors** to color a +finished game's score by how your favorite team did - green for a win, red for +a loss. + +```json +{ + "customization": { + "favorite_result_colors": { + "enabled": true, + "win_color": [0, 255, 0], + "loss_color": [255, 0, 0], + "tie_color": [255, 200, 0] + } + } +} +``` + +- Off by default. Until you enable it the score is drawn in the plain white the + scorebug uses everywhere else. (Before this release the scroll/Vegas recent + card drew the final score gold, out of step with the switch view and with + every other scoreboard; it is white now.) +- Only finished games are colored. Live and upcoming cards are untouched. +- A game needs exactly one favorite team. If neither side is a favorite, or both + are, the score keeps its normal color. +- Applies to both the one-game-at-a-time switch view and the scroll/Vegas + ticker. +- The three colors are Advanced settings; leave them alone for the defaults + above. + ## Troubleshooting - **Game times look like UTC** (a 6:45pm Central first pitch showing as diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index 051a89dd..f3581f7c 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -2056,7 +2056,72 @@ } }, "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", @@ -2071,7 +2136,8 @@ "count", "at_bat_info", "player_card", - "traditional_scoreboard" + "traditional_scoreboard", + "favorite_result_colors" ], "additionalProperties": false } diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index 402441f0..69120717 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -9,7 +9,7 @@ from datetime import datetime from pathlib import Path import os -from typing import Any, Dict, Optional +from typing import Any, ClassVar, Dict, Optional, Tuple from PIL import Image, ImageDraw, ImageFont @@ -226,6 +226,143 @@ def set_rankings_cache(self, rankings: Dict[str, int]) -> None: """Set the team rankings cache for display.""" self._team_rankings_cache = rankings + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # This is the scroll/Vegas path, and it is where the setting earns its + # keep: a series against the same opponent scrolls past as several + # near-identical cards, so tinting the final score green or red is the + # only quick way to tell a win from a loss. Off by default -- the score + # keeps the color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + def _favorite_teams_for(self, game: Dict[str, Any]) -> list: + """Favorite teams that apply to this game. + + Both sources are used. Games carry the league manager's *resolved* + favorites, which is the only place dynamic groups such as AP_TOP_25 + appear expanded; the config is read as well so an edit takes effect on + already-fetched games, and so hand-built game dicts (tests, other + callers) still work. + """ + favorites = list(game.get("favorite_teams") or []) + league_config = self.config.get(str(game.get("league", "") or "")) + if isinstance(league_config, dict): + favorites += list(league_config.get("favorite_teams") or []) + else: + favorites += list(self.config.get("favorite_teams") or []) + return favorites + + @staticmethod + def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) + payload shapes, and matches on the ESPN id too, because a couple of + leagues (NRL) key favorites by id where abbreviations collide. + """ + candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] + team = game.get(f"{side}_team") + if isinstance(team, dict): + candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] + for value in candidates: + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + @staticmethod + def _side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """Numeric score for one side, from either payload shape.""" + raw = None + team = game.get(f"{side}_team") + if isinstance(team, dict) and team.get("score") is not None: + raw = team.get("score") + if raw is None: + raw = game.get(f"{side}_score") + try: + return int(float(str(raw).strip())) + except (TypeError, ValueError): + return None + + def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = { + str(team).strip().upper() + for team in self._favorite_teams_for(game) + if str(team).strip() + } + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + home_score = self._side_score(game, "home") + away_score = self._side_score(game, "away") + if home_score is None or away_score is None: + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _score_color_for(self, game: Dict[str, Any], game_type: str, default=(255, 255, 255)): + """Fill color for a game card's score. Only finished games are tinted.""" + if game_type != "recent": + return default + return self._recent_score_color(game, default) + + def _recent_score_color(self, game: Dict[str, Any], default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def render_game_card(self, game: Dict, game_type: str) -> Image.Image: """ Render a game card as a PIL Image. @@ -428,12 +565,16 @@ def _render_recent_game(self, game: Dict) -> Image.Image: status_width = draw.textlength(status_text, font=self.fonts['time']) self._draw_text_with_outline(draw, status_text, ((self.display_width - status_width) // 2, 1), self.fonts['time']) - # Score (centered) + # Score (centered). White, matching the switch-mode recent scorebug + # and every other scoreboard; this card used to be alone in drawing + # 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_y = self.display_height - 14 - self._draw_text_with_outline(draw, score_text, (score_x, score_y), self.fonts['score'], fill=(255, 200, 0)) + self._draw_text_with_outline(draw, score_text, (score_x, score_y), + self.fonts['score'], + fill=self._recent_score_color(game, (255, 255, 255))) # Records at bottom corners self._draw_records(draw, game) diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index 66998a04..b7957532 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.22.2", + "version": "1.23.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.23.0", + "released": "2026-08-05", + "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color. Also changes the recent card's untinted score from gold to white, matching this plugin's own switch-mode scorebug and every other scoreboard; it was the only card drawing a final score gold.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.22.2", "released": "2026-08-05", diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py index 2fb7ac10..e902b981 100644 --- a/plugins/baseball-scoreboard/sports.py +++ b/plugins/baseball-scoreboard/sports.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional, Tuple import pytz import requests @@ -411,6 +411,103 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: self.logger.debug(f"Error reading layout offset for {element}.{axis}: {e}, using default {default}") return default + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # In scroll and Vegas modes the same two logos cycle past over and over -- + # a four-game series against a division rival is four near-identical cards + # -- and picking out which side is yours from the digits alone is the whole + # problem. Tinting the final score by how the favorite did makes it + # readable at a glance. Off by default, so an existing install keeps the + # score color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + @staticmethod + def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Both the abbreviation and the ESPN id are checked, because a couple of + leagues (NRL) match favorites by id where abbreviations collide. + """ + for key in (f"{side}_abbr", f"{side}_id"): + value = game.get(key) + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + def _favorite_result(self, game: Dict) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = getattr(self, "favorite_teams", None) or [] + favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + try: + # int(float(...)) to match GameRenderer._side_score exactly -- the + # two paths must agree on what counts as a usable score. + home_score = int(float(str(game.get("home_score", "")).strip())) + away_score = int(float(str(game.get("away_score", "")).strip())) + except (TypeError, ValueError): + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _recent_score_color(self, game: Dict, default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -957,6 +1054,11 @@ def _extract_game_details_common( / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), "away_logo_url": away_team["team"].get("logo"), "is_within_window": True, # Whether game is within display window + # The resolved favorites for this league (dynamic groups such + # as AP_TOP_25 already expanded). Carried on the game so the + # scroll/Vegas renderer, which only ever sees the game dict and + # the raw config, can color a final score by the result. + "favorite_teams": list(self.favorite_teams or []), } return details, home_team, away_team, status, situation except Exception as e: @@ -1963,7 +2065,11 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: score_x = (display_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') score_y = (display_height // 2) - 3 + self._get_layout_offset('score', 'y_offset') # Centered vertically, same as live games self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + draw_overlay, + score_text, + (score_x, score_y), + self.fonts["score"], + fill=self._recent_score_color(game, (255, 255, 255)), ) # Game date (Bottom of display, one line above bottom edge, centered) with layout offsets diff --git a/plugins/baseball-scoreboard/test_favorite_result_colors.py b/plugins/baseball-scoreboard/test_favorite_result_colors.py new file mode 100644 index 00000000..09f60585 --- /dev/null +++ b/plugins/baseball-scoreboard/test_favorite_result_colors.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +""" +Tests for customization.favorite_result_colors -- tinting a finished game's +score green when a favorite team won and red when it lost. + +Covers both render paths, because they resolve the color independently: + * SportsCore._recent_score_color (switch mode, sports.py) + * GameRenderer._recent_score_color (scroll/Vegas cards, game_renderer.py) + +And the cases that must NOT be tinted: the feature off (the default), no +favorites configured, a game between two non-favorites, and a game between +two favorites -- there is no losing side worth flagging red in that one. + +Run: /bin/python plugins/baseball-scoreboard/test_favorite_result_colors.py +""" + +import logging +import os +import sys + +PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__)) +if PLUGIN_DIR not in sys.path: + sys.path.insert(0, PLUGIN_DIR) + +from game_renderer import GameRenderer # noqa: E402 +from sports import SportsCore # noqa: E402 + +WHITE = (255, 255, 255) +AMBER = (255, 200, 0) # the built-in tie color +GREEN = (0, 255, 0) +RED = (255, 0, 0) + +ON = {"customization": {"favorite_result_colors": {"enabled": True}}} + + +def _game(home, away, home_score, away_score, favorites=None, league="mlb"): + game = { + "id": f"{away}@{home}", + "league": league, + "home_abbr": home, + "away_abbr": away, + "home_id": f"id-{home}", + "away_id": f"id-{away}", + "home_score": home_score, + "away_score": away_score, + "is_final": True, + } + if favorites is not None: + game["favorite_teams"] = favorites + return game + + +class _ConcreteCore(SportsCore): + """Concrete SportsCore so it can be instantiated without the manager stack.""" + + def _extract_game_details(self, game): # abstract in SportsCore + return None + + def _fetch_data(self): # abstract in SportsCore + return None + + +def _core(favorite_teams, config=None): + """A SportsCore with just the attributes the color helpers touch.""" + core = object.__new__(_ConcreteCore) + core.logger = logging.getLogger("test_favorite_result_colors") + core.config = config if config is not None else dict(ON) + core.favorite_teams = favorite_teams + return core + + +def _renderer(config): + renderer = object.__new__(GameRenderer) + renderer.logger = logging.getLogger("test_favorite_result_colors") + renderer.config = config + return renderer + + +def _check(label, actual, expected): + assert actual == expected, f"{label}: expected {expected}, got {actual}" + print(f" PASS {label}") + + +def test_switch_mode_colors(): + print("\n[switch mode] SportsCore._recent_score_color") + core = _core(["ATL"]) + + _check("favorite won at home", + core._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), GREEN) + _check("favorite won away", + core._recent_score_color(_game("NYM", "ATL", "2", "5"), WHITE), GREEN) + _check("favorite lost at home", + core._recent_score_color(_game("ATL", "NYM", "1", "7"), WHITE), RED) + _check("favorite lost away", + core._recent_score_color(_game("NYM", "ATL", "7", "1"), WHITE), RED) + _check("tie", + core._recent_score_color(_game("ATL", "NYM", "3", "3"), WHITE), AMBER) + _check("neither team is a favorite", + core._recent_score_color(_game("NYM", "PHI", "4", "1"), WHITE), WHITE) + + +def test_disabled_by_default(): + print("\n[default off]") + core = _core(["ATL"], config={}) + _check("no customization block", + core._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), WHITE) + + core = _core(["ATL"], config={"customization": {"favorite_result_colors": {}}}) + _check("block present, enabled omitted", + core._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), WHITE) + + +def test_no_single_team_to_root_for(): + print("\n[no single favorite]") + _check("no favorites configured", + _core([])._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), WHITE) + _check("both teams are favorites", + _core(["ATL", "NYM"])._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), + WHITE) + + +def test_unusable_scores(): + print("\n[unusable scores]") + core = _core(["ATL"]) + _check("postponed, no score", + core._recent_score_color(_game("ATL", "NYM", "", ""), WHITE), WHITE) + _check("non-numeric score", + core._recent_score_color(_game("ATL", "NYM", "-", "-"), WHITE), WHITE) + + +def test_score_parsing_matches_across_paths(): + """The two render paths must agree on what counts as a usable score. + + They resolve the colour independently, so a value one accepts and the + other rejects would colour a game in the ticker but not the switch view. + """ + print("\n[score parsing parity]") + core = _core(["ATL"]) + renderer = _renderer({"customization": {"favorite_result_colors": {"enabled": True}}, + "mlb": {"favorite_teams": ["ATL"]}}) + for home, away, expected in [("5", "2", GREEN), ("5.0", "2.0", GREEN), + ("2", "5", RED), ("", "", WHITE), ("-", "-", WHITE)]: + game = _game("ATL", "NYM", home, away) + switch = core._recent_score_color(game, WHITE) + scroll = renderer._recent_score_color(game, WHITE) + _check(f"switch and scroll agree on {home!r}-{away!r}", (switch, scroll), + (expected, expected)) + + +def test_custom_colors(): + print("\n[custom colors]") + core = _core(["ATL"], config={"customization": {"favorite_result_colors": { + "enabled": True, + "win_color": [0, 128, 255], + "loss_color": [128, 0, 128], + }}}) + _check("configured win color", + core._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), (0, 128, 255)) + _check("configured loss color", + core._recent_score_color(_game("ATL", "NYM", "2", "5"), WHITE), (128, 0, 128)) + + core = _core(["ATL"], config={"customization": {"favorite_result_colors": { + "enabled": True, + "win_color": [999, -5, "12"], + }}}) + _check("out-of-range channels are clamped", + core._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), (255, 0, 12)) + + for junk in ("not a color", "123", [1, 2], [1, 2, 3, 4], 42, None, + {"r": 1, "g": 2, "b": 3}): + core = _core(["ATL"], config={"customization": {"favorite_result_colors": { + "enabled": True, + "win_color": junk, + }}}) + # "123" is the interesting one: it is iterable and three items long, so + # an unguarded unpack turns it into the near-black (1, 2, 3). + _check(f"malformed color {junk!r} falls back to the built-in win color", + core._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), GREEN) + + +def test_scroll_card_colors(): + print("\n[scroll/Vegas] GameRenderer._recent_score_color") + renderer = _renderer({"customization": {"favorite_result_colors": {"enabled": True}}, + "mlb": {"favorite_teams": ["ATL"]}}) + + # With the feature off the caller's own default is handed straight back, + # whatever it is, so an existing install sees no change until it opts in. + off = _renderer({"mlb": {"favorite_teams": ["ATL"]}}) + _check("feature off keeps the card's own default", + off._recent_score_color(_game("ATL", "NYM", "5", "2"), WHITE), WHITE) + + _check("favorite won", renderer._recent_score_color( + _game("ATL", "NYM", "5", "2"), WHITE), GREEN) + _check("favorite lost", renderer._recent_score_color( + _game("ATL", "NYM", "2", "5"), WHITE), RED) + _check("other matchup untouched", renderer._recent_score_color( + _game("NYM", "PHI", "4", "1"), WHITE), WHITE) + + # The renderer has its own tie branch, independent of SportsCore's. + _check("tie uses the built-in tie color", renderer._recent_score_color( + _game("ATL", "NYM", "3", "3"), WHITE), AMBER) + tie_configured = _renderer({ + "customization": {"favorite_result_colors": { + "enabled": True, "tie_color": [80, 80, 255]}}, + "mlb": {"favorite_teams": ["ATL"]}, + }) + _check("tie uses the configured tie color", tie_configured._recent_score_color( + _game("ATL", "NYM", "3", "3"), WHITE), (80, 80, 255)) + + # Only finished games are tinted; a live card keeps its normal color even + # when the favorite happens to be ahead. + _check("live game not tinted", renderer._score_color_for( + _game("ATL", "NYM", "5", "2"), "live"), WHITE) + _check("upcoming game not tinted", renderer._score_color_for( + _game("ATL", "NYM", "0", "0"), "upcoming"), WHITE) + _check("recent game tinted", renderer._score_color_for( + _game("ATL", "NYM", "5", "2"), "recent"), GREEN) + + +def test_scroll_card_favorite_sources(): + print("\n[scroll/Vegas] where favorites come from") + + # Soccer-style config, where favorites live under a per-league section the + # renderer cannot find by name -- the game's stamped list carries them. + stamped = _renderer({"customization": {"favorite_result_colors": {"enabled": True}}}) + _check("favorites stamped on the game", stamped._recent_score_color( + _game("ATL", "NYM", "5", "2", favorites=["ATL"]), WHITE), GREEN) + + # A league section the renderer can find, with nothing stamped (hand-built + # game dicts, and config edits that beat the next data refresh). + from_config = _renderer({"customization": {"favorite_result_colors": {"enabled": True}}, + "mlb": {"favorite_teams": ["ATL"]}}) + _check("favorites read from config", from_config._recent_score_color( + _game("ATL", "NYM", "5", "2"), WHITE), GREEN) + + # NRL keys favorites by ESPN id because its abbreviations collide. + by_id = _renderer({"customization": {"favorite_result_colors": {"enabled": True}}, + "nrl": {"favorite_teams": ["id-ATL"]}}) + _check("favorites matched by id", by_id._recent_score_color( + _game("ATL", "NYM", "5", "2", league="nrl"), WHITE), GREEN) + + +def test_scroll_card_nested_payload(): + print("\n[scroll/Vegas] nested payload (hockey/lacrosse shape)") + renderer = _renderer({"customization": {"favorite_result_colors": {"enabled": True}}, + "nhl": {"favorite_teams": ["TB"]}}) + game = { + "league": "nhl", + "is_final": True, + "home_team": {"abbrev": "TB", "score": "4"}, + "away_team": {"abbrev": "BOS", "score": "1"}, + } + _check("nested favorite won", renderer._recent_score_color(game, WHITE), GREEN) + + game["home_team"]["score"] = "0" + _check("nested favorite lost", renderer._recent_score_color(game, WHITE), RED) + + +def main(): + tests = [ + test_switch_mode_colors, + test_disabled_by_default, + test_no_single_team_to_root_for, + test_unusable_scores, + test_score_parsing_matches_across_paths, + test_custom_colors, + test_scroll_card_colors, + test_scroll_card_favorite_sources, + test_scroll_card_nested_payload, + ] + for test in tests: + test() + print(f"\nAll {len(tests)} test groups passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/basketball-scoreboard/README.md b/plugins/basketball-scoreboard/README.md index ac691a58..a973b8d9 100644 --- a/plugins/basketball-scoreboard/README.md +++ b/plugins/basketball-scoreboard/README.md @@ -27,6 +27,7 @@ A plugin for LEDMatrix that displays live, recent, and upcoming basketball games - **Per-League Configuration**: Independent settings for each league - **Flexible Display Options**: Show records, rankings, and betting odds - **Advanced Filtering**: Control which teams and games are displayed +- **Favorite Team Result Colors**: Optionally show a finished game's score in green when your favorite team won and red when it lost ## Configuration @@ -385,6 +386,37 @@ shorten). Leave it at `0` to display every live game for `live_game_duration`. } ``` +## Favorite Team Result Colors + +A run of games against the same opponent is hard to read at a glance: in scroll +and Vegas mode the same two logos go past several times and only the digits +change. Turn on **Customization -> Favorite Team Result Colors** to color a +finished game's score by how your favorite team did - green for a win, red for +a loss. + +```json +{ + "customization": { + "favorite_result_colors": { + "enabled": true, + "win_color": [0, 255, 0], + "loss_color": [255, 0, 0], + "tie_color": [255, 200, 0] + } + } +} +``` + +- Off by default. Until you enable it the score keeps exactly the color it has + today. +- Only finished games are colored. Live and upcoming cards are untouched. +- A game needs exactly one favorite team. If neither side is a favorite, or both + are, the score keeps its normal color. +- Applies to both the one-game-at-a-time switch view and the scroll/Vegas + ticker. +- The three colors are Advanced settings; leave them alone for the defaults + above. + ## Troubleshooting - **Start times look like UTC** (a 6:45pm Central start showing as 11:45PM): diff --git a/plugins/basketball-scoreboard/config_schema.json b/plugins/basketball-scoreboard/config_schema.json index d437602e..5b813de6 100644 --- a/plugins/basketball-scoreboard/config_schema.json +++ b/plugins/basketball-scoreboard/config_schema.json @@ -1977,9 +1977,74 @@ }, "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"], + "x-propertyOrder": ["score_text", "period_text", "team_name", "status_text", "detail_text", "rank_text", "layout", "favorite_result_colors"], "additionalProperties": false } }, diff --git a/plugins/basketball-scoreboard/game_renderer.py b/plugins/basketball-scoreboard/game_renderer.py index a3c66b0b..520aaa7c 100644 --- a/plugins/basketball-scoreboard/game_renderer.py +++ b/plugins/basketball-scoreboard/game_renderer.py @@ -8,7 +8,7 @@ import logging import os from pathlib import Path -from typing import Dict, Any, Optional, Tuple +from typing import Any, ClassVar, Dict, Optional, Tuple from PIL import Image, ImageDraw, ImageFont logger = logging.getLogger(__name__) @@ -281,6 +281,143 @@ def _draw_text_with_outline( draw.text((x + dx, y + dy), text, font=font, fill=outline_color) draw.text((x, y), text, font=font, fill=fill) + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # This is the scroll/Vegas path, and it is where the setting earns its + # keep: a series against the same opponent scrolls past as several + # near-identical cards, so tinting the final score green or red is the + # only quick way to tell a win from a loss. Off by default -- the score + # keeps the color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + def _favorite_teams_for(self, game: Dict[str, Any]) -> list: + """Favorite teams that apply to this game. + + Both sources are used. Games carry the league manager's *resolved* + favorites, which is the only place dynamic groups such as AP_TOP_25 + appear expanded; the config is read as well so an edit takes effect on + already-fetched games, and so hand-built game dicts (tests, other + callers) still work. + """ + favorites = list(game.get("favorite_teams") or []) + league_config = self.config.get(str(game.get("league", "") or "")) + if isinstance(league_config, dict): + favorites += list(league_config.get("favorite_teams") or []) + else: + favorites += list(self.config.get("favorite_teams") or []) + return favorites + + @staticmethod + def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) + payload shapes, and matches on the ESPN id too, because a couple of + leagues (NRL) key favorites by id where abbreviations collide. + """ + candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] + team = game.get(f"{side}_team") + if isinstance(team, dict): + candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] + for value in candidates: + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + @staticmethod + def _side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """Numeric score for one side, from either payload shape.""" + raw = None + team = game.get(f"{side}_team") + if isinstance(team, dict) and team.get("score") is not None: + raw = team.get("score") + if raw is None: + raw = game.get(f"{side}_score") + try: + return int(float(str(raw).strip())) + except (TypeError, ValueError): + return None + + def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = { + str(team).strip().upper() + for team in self._favorite_teams_for(game) + if str(team).strip() + } + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + home_score = self._side_score(game, "home") + away_score = self._side_score(game, "away") + if home_score is None or away_score is None: + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _score_color_for(self, game: Dict[str, Any], game_type: str, default=(255, 255, 255)): + """Fill color for a game card's score. Only finished games are tinted.""" + if game_type != "recent": + return default + return self._recent_score_color(game, default) + + def _recent_score_color(self, game: Dict[str, Any], default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def render_game_card( self, game: Dict[str, Any], @@ -357,7 +494,10 @@ def render_game_card( 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']) + 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 period/status based on game type if game_type == "live": diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 294f2164..626baf64 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.10.3", + "version": "1.11.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.11.0", + "released": "2026-08-05", + "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.10.3", "released": "2026-08-05", diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index 59293014..cc1ba50a 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, ClassVar, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional, Tuple import pytz import requests @@ -313,6 +313,103 @@ def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], self.logger.error(f"Error loading default font: {e}") return ImageFont.load_default() + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # In scroll and Vegas modes the same two logos cycle past over and over -- + # a four-game series against a division rival is four near-identical cards + # -- and picking out which side is yours from the digits alone is the whole + # problem. Tinting the final score by how the favorite did makes it + # readable at a glance. Off by default, so an existing install keeps the + # score color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + @staticmethod + def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Both the abbreviation and the ESPN id are checked, because a couple of + leagues (NRL) match favorites by id where abbreviations collide. + """ + for key in (f"{side}_abbr", f"{side}_id"): + value = game.get(key) + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + def _favorite_result(self, game: Dict) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = getattr(self, "favorite_teams", None) or [] + favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + try: + # int(float(...)) to match GameRenderer._side_score exactly -- the + # two paths must agree on what counts as a usable score. + home_score = int(float(str(game.get("home_score", "")).strip())) + away_score = int(float(str(game.get("away_score", "")).strip())) + except (TypeError, ValueError): + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _recent_score_color(self, game: Dict, default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -1119,6 +1216,11 @@ def extract_logo_url(team_data): / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), "away_logo_url": away_logo_url, "is_within_window": True, # Whether game is within display window + # The resolved favorites for this league (dynamic groups such + # as AP_TOP_25 already expanded). Carried on the game so the + # scroll/Vegas renderer, which only ever sees the game dict and + # the raw config, can color a final score by the result. + "favorite_teams": list(self.favorite_teams or []), } # --- Tournament metadata extraction (March Madness) --- @@ -2230,7 +2332,11 @@ def format_score(score): score_x = (display_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') score_y = display_height - 14 + self._get_layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + draw_overlay, + score_text, + (score_x, score_y), + self.fonts["score"], + fill=self._recent_score_color(game, (255, 255, 255)), ) # "Final" text (Top center) with layout offsets diff --git a/plugins/football-scoreboard/README.md b/plugins/football-scoreboard/README.md index 65d53231..dfd6bb0c 100644 --- a/plugins/football-scoreboard/README.md +++ b/plugins/football-scoreboard/README.md @@ -34,6 +34,7 @@ Recent Game (NCAA FB): - **Upcoming Games**: Scheduled games with start times and odds - **Dynamic Team Resolution**: Support for `AP_TOP_25`, `AP_TOP_10`, `AP_TOP_5` automatic team selection - **Production-Ready**: Real ESPN API integration with caching and error handling +- **Favorite Team Result Colors**: Optionally show a finished game's score in green when your favorite team won and red when it lost ### Professional Display - **Team Logos**: Professional team logos with automatic download fallback @@ -399,6 +400,37 @@ Configure per league (under the `nfl` / `ncaa_fb` config sections): - `celebrate_opponent_scores` (also celebrate the opponent, default `false`; when no favorite teams are configured, any team's score celebrates) +## Favorite Team Result Colors + +A run of games against the same opponent is hard to read at a glance: in scroll +and Vegas mode the same two logos go past several times and only the digits +change. Turn on **Customization -> Favorite Team Result Colors** to color a +finished game's score by how your favorite team did - green for a win, red for +a loss. + +```json +{ + "customization": { + "favorite_result_colors": { + "enabled": true, + "win_color": [0, 255, 0], + "loss_color": [255, 0, 0], + "tie_color": [255, 200, 0] + } + } +} +``` + +- Off by default. Until you enable it the score keeps exactly the color it has + today. +- Only finished games are colored. Live and upcoming cards are untouched. +- A game needs exactly one favorite team. If neither side is a favorite, or both + are, the score keeps its normal color. +- Applies to both the one-game-at-a-time switch view and the scroll/Vegas + ticker. +- The three colors are Advanced settings; leave them alone for the defaults + above. + ## 🏷️ Team Abbreviations ### NFL Teams diff --git a/plugins/football-scoreboard/config_schema.json b/plugins/football-scoreboard/config_schema.json index 0eddcb8e..7686097d 100644 --- a/plugins/football-scoreboard/config_schema.json +++ b/plugins/football-scoreboard/config_schema.json @@ -1207,9 +1207,74 @@ }, "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 + }, + "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"], + "x-propertyOrder": ["score_text", "period_text", "team_name", "status_text", "detail_text", "rank_text", "layout", "favorite_result_colors"], "additionalProperties": false } }, diff --git a/plugins/football-scoreboard/game_renderer.py b/plugins/football-scoreboard/game_renderer.py index d370dbc1..1cf52a09 100644 --- a/plugins/football-scoreboard/game_renderer.py +++ b/plugins/football-scoreboard/game_renderer.py @@ -14,7 +14,7 @@ import logging import os from pathlib import Path -from typing import Dict, Any, Optional, Tuple, Union +from typing import Any, ClassVar, Dict, Optional, Tuple, Union from PIL import Image, ImageDraw, ImageFont try: import freetype # noqa: F401 @@ -372,6 +372,143 @@ def _draw_text_with_outline( draw.text((x + dx, y + dy), text, font=font, fill=outline_color) draw.text((x, y), text, font=font, fill=fill) + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # This is the scroll/Vegas path, and it is where the setting earns its + # keep: a series against the same opponent scrolls past as several + # near-identical cards, so tinting the final score green or red is the + # only quick way to tell a win from a loss. Off by default -- the score + # keeps the color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + def _favorite_teams_for(self, game: Dict[str, Any]) -> list: + """Favorite teams that apply to this game. + + Both sources are used. Games carry the league manager's *resolved* + favorites, which is the only place dynamic groups such as AP_TOP_25 + appear expanded; the config is read as well so an edit takes effect on + already-fetched games, and so hand-built game dicts (tests, other + callers) still work. + """ + favorites = list(game.get("favorite_teams") or []) + league_config = self.config.get(str(game.get("league", "") or "")) + if isinstance(league_config, dict): + favorites += list(league_config.get("favorite_teams") or []) + else: + favorites += list(self.config.get("favorite_teams") or []) + return favorites + + @staticmethod + def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) + payload shapes, and matches on the ESPN id too, because a couple of + leagues (NRL) key favorites by id where abbreviations collide. + """ + candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] + team = game.get(f"{side}_team") + if isinstance(team, dict): + candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] + for value in candidates: + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + @staticmethod + def _side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """Numeric score for one side, from either payload shape.""" + raw = None + team = game.get(f"{side}_team") + if isinstance(team, dict) and team.get("score") is not None: + raw = team.get("score") + if raw is None: + raw = game.get(f"{side}_score") + try: + return int(float(str(raw).strip())) + except (TypeError, ValueError): + return None + + def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = { + str(team).strip().upper() + for team in self._favorite_teams_for(game) + if str(team).strip() + } + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + home_score = self._side_score(game, "home") + away_score = self._side_score(game, "away") + if home_score is None or away_score is None: + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _score_color_for(self, game: Dict[str, Any], game_type: str, default=(255, 255, 255)): + """Fill color for a game card's score. Only finished games are tinted.""" + if game_type != "recent": + return default + return self._recent_score_color(game, default) + + def _recent_score_color(self, game: Dict[str, Any], default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def render_game_card( self, game: Dict[str, Any], @@ -441,7 +578,10 @@ def render_game_card( 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']) + 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 period/status based on game type if game_type == "live": @@ -655,7 +795,8 @@ def _render_game_card_adaptive(self, game: Dict[str, Any], 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) + self._draw_fit_outline(draw_overlay, score_fit, score_region, + fill=self._score_color_for(game, game_type)) if game_type == "live": self._draw_live_status_adaptive(draw_overlay, game, regs) diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 9254aa87..4002dab8 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.11.2", + "version": "2.12.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.12.0", + "released": "2026-08-05", + "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.11.2", "released": "2026-08-05", diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index c5f2852d..fe9890aa 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional, Tuple import pytz import requests @@ -386,6 +386,103 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: self.logger.debug(f"Error reading layout offset for {element}.{axis}: {e}, using default {default}") return default + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # In scroll and Vegas modes the same two logos cycle past over and over -- + # a four-game series against a division rival is four near-identical cards + # -- and picking out which side is yours from the digits alone is the whole + # problem. Tinting the final score by how the favorite did makes it + # readable at a glance. Off by default, so an existing install keeps the + # score color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + @staticmethod + def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Both the abbreviation and the ESPN id are checked, because a couple of + leagues (NRL) match favorites by id where abbreviations collide. + """ + for key in (f"{side}_abbr", f"{side}_id"): + value = game.get(key) + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + def _favorite_result(self, game: Dict) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = getattr(self, "favorite_teams", None) or [] + favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + try: + # int(float(...)) to match GameRenderer._side_score exactly -- the + # two paths must agree on what counts as a usable score. + home_score = int(float(str(game.get("home_score", "")).strip())) + away_score = int(float(str(game.get("away_score", "")).strip())) + except (TypeError, ValueError): + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _recent_score_color(self, game: Dict, default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -917,6 +1014,11 @@ def _extract_game_details_common( / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), "away_logo_url": away_team["team"].get("logo"), "is_within_window": True, # Whether game is within display window + # The resolved favorites for this league (dynamic groups such + # as AP_TOP_25 already expanded). Carried on the game so the + # scroll/Vegas renderer, which only ever sees the game dict and + # the raw config, can color a final score by the result. + "favorite_teams": list(self.favorite_teams or []), } return details, home_team, away_team, status, situation except Exception as e: @@ -1931,7 +2033,11 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: score_x = (display_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') score_y = (display_height // 2) - 3 + self._get_layout_offset('score', 'y_offset') # Centered vertically, same as live games self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + draw_overlay, + score_text, + (score_x, score_y), + self.fonts["score"], + fill=self._recent_score_color(game, (255, 255, 255)), ) # Game date (Bottom of display, one line above bottom edge, centered) with layout offsets diff --git a/plugins/hockey-scoreboard/README.md b/plugins/hockey-scoreboard/README.md index c92e4f79..2d5653a0 100644 --- a/plugins/hockey-scoreboard/README.md +++ b/plugins/hockey-scoreboard/README.md @@ -39,6 +39,7 @@ Upcoming Game: - **Team Logos**: Display team logos when available - **Background Data Fetching**: Efficient API calls with caching - **Font Customization**: Override fonts via Web UI +- **Favorite Team Result Colors**: Optionally show a finished game's score in green when your favorite team won and red when it lost ## Requirements @@ -370,6 +371,37 @@ Add your favorite team abbreviations to the `favorite_teams` object for each lea Make sure `enabled: true` in the configuration and the plugin is activated in the rotation. +## Favorite Team Result Colors + +A run of games against the same opponent is hard to read at a glance: in scroll +and Vegas mode the same two logos go past several times and only the digits +change. Turn on **Customization -> Favorite Team Result Colors** to color a +finished game's score by how your favorite team did - green for a win, red for +a loss. + +```json +{ + "customization": { + "favorite_result_colors": { + "enabled": true, + "win_color": [0, 255, 0], + "loss_color": [255, 0, 0], + "tie_color": [255, 200, 0] + } + } +} +``` + +- Off by default. Until you enable it the score keeps exactly the color it has + today. +- Only finished games are colored. Live and upcoming cards are untouched. +- A game needs exactly one favorite team. If neither side is a favorite, or both + are, the score keeps its normal color. +- Applies to both the one-game-at-a-time switch view and the scroll/Vegas + ticker. +- The three colors are Advanced settings; leave them alone for the defaults + above. + ## Troubleshooting **No games showing:** diff --git a/plugins/hockey-scoreboard/config_schema.json b/plugins/hockey-scoreboard/config_schema.json index d36ad807..4b2110f9 100644 --- a/plugins/hockey-scoreboard/config_schema.json +++ b/plugins/hockey-scoreboard/config_schema.json @@ -1624,9 +1624,74 @@ }, "x-propertyOrder": ["home_logo", "away_logo", "score", "status_text", "date", "time", "records"], "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"], + "x-propertyOrder": ["score_text", "period_text", "team_name", "status_text", "detail_text", "rank_text", "layout", "favorite_result_colors"], "additionalProperties": false } }, diff --git a/plugins/hockey-scoreboard/game_renderer.py b/plugins/hockey-scoreboard/game_renderer.py index efbcbf60..a93d85b7 100644 --- a/plugins/hockey-scoreboard/game_renderer.py +++ b/plugins/hockey-scoreboard/game_renderer.py @@ -8,7 +8,7 @@ import logging import os from pathlib import Path -from typing import Dict, Any, Optional, Tuple +from typing import Any, ClassVar, Dict, Optional, Tuple from PIL import Image, ImageDraw, ImageFont logger = logging.getLogger(__name__) @@ -316,6 +316,143 @@ def _normalize_game_payload(self, game: Dict[str, Any]) -> Dict[str, Any]: return normalized + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # This is the scroll/Vegas path, and it is where the setting earns its + # keep: a series against the same opponent scrolls past as several + # near-identical cards, so tinting the final score green or red is the + # only quick way to tell a win from a loss. Off by default -- the score + # keeps the color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + def _favorite_teams_for(self, game: Dict[str, Any]) -> list: + """Favorite teams that apply to this game. + + Both sources are used. Games carry the league manager's *resolved* + favorites, which is the only place dynamic groups such as AP_TOP_25 + appear expanded; the config is read as well so an edit takes effect on + already-fetched games, and so hand-built game dicts (tests, other + callers) still work. + """ + favorites = list(game.get("favorite_teams") or []) + league_config = self.config.get(str(game.get("league", "") or "")) + if isinstance(league_config, dict): + favorites += list(league_config.get("favorite_teams") or []) + else: + favorites += list(self.config.get("favorite_teams") or []) + return favorites + + @staticmethod + def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) + payload shapes, and matches on the ESPN id too, because a couple of + leagues (NRL) key favorites by id where abbreviations collide. + """ + candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] + team = game.get(f"{side}_team") + if isinstance(team, dict): + candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] + for value in candidates: + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + @staticmethod + def _side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """Numeric score for one side, from either payload shape.""" + raw = None + team = game.get(f"{side}_team") + if isinstance(team, dict) and team.get("score") is not None: + raw = team.get("score") + if raw is None: + raw = game.get(f"{side}_score") + try: + return int(float(str(raw).strip())) + except (TypeError, ValueError): + return None + + def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = { + str(team).strip().upper() + for team in self._favorite_teams_for(game) + if str(team).strip() + } + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + home_score = self._side_score(game, "home") + away_score = self._side_score(game, "away") + if home_score is None or away_score is None: + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _score_color_for(self, game: Dict[str, Any], game_type: str, default=(255, 255, 255)): + """Fill color for a game card's score. Only finished games are tinted.""" + if game_type != "recent": + return default + return self._recent_score_color(game, default) + + def _recent_score_color(self, game: Dict[str, Any], default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def render_game_card( self, game: Dict[str, Any], @@ -387,7 +524,10 @@ def render_game_card( 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']) + 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" diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index cab5a1f8..a91f069d 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.7.3", + "version": "1.8.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.8.0", + "released": "2026-08-05", + "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.7.3", "released": "2026-08-05", diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index 10925cdb..8820996c 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional, Tuple import pytz import requests @@ -337,6 +337,103 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: self.logger.debug(f"Error reading layout offset for {element}.{axis}: {e}, using default {default}") return default + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # In scroll and Vegas modes the same two logos cycle past over and over -- + # a four-game series against a division rival is four near-identical cards + # -- and picking out which side is yours from the digits alone is the whole + # problem. Tinting the final score by how the favorite did makes it + # readable at a glance. Off by default, so an existing install keeps the + # score color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + @staticmethod + def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Both the abbreviation and the ESPN id are checked, because a couple of + leagues (NRL) match favorites by id where abbreviations collide. + """ + for key in (f"{side}_abbr", f"{side}_id"): + value = game.get(key) + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + def _favorite_result(self, game: Dict) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = getattr(self, "favorite_teams", None) or [] + favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + try: + # int(float(...)) to match GameRenderer._side_score exactly -- the + # two paths must agree on what counts as a usable score. + home_score = int(float(str(game.get("home_score", "")).strip())) + away_score = int(float(str(game.get("away_score", "")).strip())) + except (TypeError, ValueError): + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _recent_score_color(self, game: Dict, default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -837,6 +934,11 @@ def _extract_game_details_common( / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), "away_logo_url": away_team["team"].get("logo"), "is_within_window": True, # Whether game is within display window + # The resolved favorites for this league (dynamic groups such + # as AP_TOP_25 already expanded). Carried on the game so the + # scroll/Vegas renderer, which only ever sees the game dict and + # the raw config, can color a final score by the result. + "favorite_teams": list(self.favorite_teams or []), } return details, home_team, away_team, status, situation except Exception as e: @@ -1756,7 +1858,11 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: score_x = (self.display_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') score_y = self.display_height - 14 + self._get_layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + draw_overlay, + score_text, + (score_x, score_y), + self.fonts["score"], + fill=self._recent_score_color(game, (255, 255, 255)), ) # "Final" text (Top center) with layout offsets diff --git a/plugins/lacrosse-scoreboard/README.md b/plugins/lacrosse-scoreboard/README.md index a7fd3511..8c747561 100644 --- a/plugins/lacrosse-scoreboard/README.md +++ b/plugins/lacrosse-scoreboard/README.md @@ -23,6 +23,7 @@ Live, recent, and upcoming NCAA Men's and Women's Lacrosse games on your LEDMatr - **Poll rank badges** — `#1`, `#2` overlays on team names, updated hourly from ESPN's public rankings feed - **Element customization** — toggle records, rankings, odds, shot totals; override layout offsets for logos, score, and status text - **Configurable durations, update intervals, and game counts** per league +- **Favorite Team Result Colors**: Optionally show a finished game's score in green when your favorite team won and red when it lost ## Requirements @@ -250,6 +251,37 @@ Scores and schedules come from ESPN's public site API: Team logos are fetched from `https://a.espncdn.com/i/teamlogos/ncaa/500/{team_id}.png` and cached locally under `assets/sports/ncaa_logos/`. +## Favorite Team Result Colors + +A run of games against the same opponent is hard to read at a glance: in scroll +and Vegas mode the same two logos go past several times and only the digits +change. Turn on **Customization -> Favorite Team Result Colors** to color a +finished game's score by how your favorite team did - green for a win, red for +a loss. + +```json +{ + "customization": { + "favorite_result_colors": { + "enabled": true, + "win_color": [0, 255, 0], + "loss_color": [255, 0, 0], + "tie_color": [255, 200, 0] + } + } +} +``` + +- Off by default. Until you enable it the score keeps exactly the color it has + today. +- Only finished games are colored. Live and upcoming cards are untouched. +- A game needs exactly one favorite team. If neither side is a favorite, or both + are, the score keeps its normal color. +- Applies to both the one-game-at-a-time switch view and the scroll/Vegas + ticker. +- The three colors are Advanced settings; leave them alone for the defaults + above. + ## Troubleshooting diff --git a/plugins/lacrosse-scoreboard/config_schema.json b/plugins/lacrosse-scoreboard/config_schema.json index 15dc6317..479d230c 100644 --- a/plugins/lacrosse-scoreboard/config_schema.json +++ b/plugins/lacrosse-scoreboard/config_schema.json @@ -1259,6 +1259,71 @@ "records" ], "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": [ @@ -1268,7 +1333,8 @@ "status_text", "detail_text", "rank_text", - "layout" + "layout", + "favorite_result_colors" ], "additionalProperties": false } diff --git a/plugins/lacrosse-scoreboard/game_renderer.py b/plugins/lacrosse-scoreboard/game_renderer.py index 88834ae1..9b66b7ad 100644 --- a/plugins/lacrosse-scoreboard/game_renderer.py +++ b/plugins/lacrosse-scoreboard/game_renderer.py @@ -8,7 +8,7 @@ import logging import os from pathlib import Path -from typing import Dict, Any, Optional, Tuple +from typing import Any, ClassVar, Dict, Optional, Tuple from PIL import Image, ImageDraw, ImageFont logger = logging.getLogger(__name__) @@ -325,6 +325,143 @@ def _normalize_game_payload(self, game: Dict[str, Any]) -> Dict[str, Any]: return normalized + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # This is the scroll/Vegas path, and it is where the setting earns its + # keep: a series against the same opponent scrolls past as several + # near-identical cards, so tinting the final score green or red is the + # only quick way to tell a win from a loss. Off by default -- the score + # keeps the color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + def _favorite_teams_for(self, game: Dict[str, Any]) -> list: + """Favorite teams that apply to this game. + + Both sources are used. Games carry the league manager's *resolved* + favorites, which is the only place dynamic groups such as AP_TOP_25 + appear expanded; the config is read as well so an edit takes effect on + already-fetched games, and so hand-built game dicts (tests, other + callers) still work. + """ + favorites = list(game.get("favorite_teams") or []) + league_config = self.config.get(str(game.get("league", "") or "")) + if isinstance(league_config, dict): + favorites += list(league_config.get("favorite_teams") or []) + else: + favorites += list(self.config.get("favorite_teams") or []) + return favorites + + @staticmethod + def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) + payload shapes, and matches on the ESPN id too, because a couple of + leagues (NRL) key favorites by id where abbreviations collide. + """ + candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] + team = game.get(f"{side}_team") + if isinstance(team, dict): + candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] + for value in candidates: + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + @staticmethod + def _side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """Numeric score for one side, from either payload shape.""" + raw = None + team = game.get(f"{side}_team") + if isinstance(team, dict) and team.get("score") is not None: + raw = team.get("score") + if raw is None: + raw = game.get(f"{side}_score") + try: + return int(float(str(raw).strip())) + except (TypeError, ValueError): + return None + + def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = { + str(team).strip().upper() + for team in self._favorite_teams_for(game) + if str(team).strip() + } + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + home_score = self._side_score(game, "home") + away_score = self._side_score(game, "away") + if home_score is None or away_score is None: + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _score_color_for(self, game: Dict[str, Any], game_type: str, default=(255, 255, 255)): + """Fill color for a game card's score. Only finished games are tinted.""" + if game_type != "recent": + return default + return self._recent_score_color(game, default) + + def _recent_score_color(self, game: Dict[str, Any], default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def render_game_card( self, game: Dict[str, Any], @@ -396,7 +533,10 @@ def render_game_card( 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']) + 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 period/status based on game type if game_type == "live": diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index 0853526b..76260a2b 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.7.3", + "version": "1.8.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.8.0", + "released": "2026-08-05", + "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.7.3", "released": "2026-08-05", diff --git a/plugins/lacrosse-scoreboard/sports.py b/plugins/lacrosse-scoreboard/sports.py index 0c75aa31..ea991e14 100644 --- a/plugins/lacrosse-scoreboard/sports.py +++ b/plugins/lacrosse-scoreboard/sports.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, ClassVar, Dict, List, Optional, Tuple import pytz import requests @@ -338,6 +338,103 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: self.logger.debug(f"Error reading layout offset for {element}.{axis}: {e}, using default {default}") return default + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # In scroll and Vegas modes the same two logos cycle past over and over -- + # a four-game series against a division rival is four near-identical cards + # -- and picking out which side is yours from the digits alone is the whole + # problem. Tinting the final score by how the favorite did makes it + # readable at a glance. Off by default, so an existing install keeps the + # score color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + @staticmethod + def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Both the abbreviation and the ESPN id are checked, because a couple of + leagues (NRL) match favorites by id where abbreviations collide. + """ + for key in (f"{side}_abbr", f"{side}_id"): + value = game.get(key) + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + def _favorite_result(self, game: Dict) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = getattr(self, "favorite_teams", None) or [] + favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + try: + # int(float(...)) to match GameRenderer._side_score exactly -- the + # two paths must agree on what counts as a usable score. + home_score = int(float(str(game.get("home_score", "")).strip())) + away_score = int(float(str(game.get("away_score", "")).strip())) + except (TypeError, ValueError): + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _recent_score_color(self, game: Dict, default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -838,6 +935,11 @@ def _extract_game_details_common( / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), "away_logo_url": away_team["team"].get("logo"), "is_within_window": True, # Whether game is within display window + # The resolved favorites for this league (dynamic groups such + # as AP_TOP_25 already expanded). Carried on the game so the + # scroll/Vegas renderer, which only ever sees the game dict and + # the raw config, can color a final score by the result. + "favorite_teams": list(self.favorite_teams or []), } return details, home_team, away_team, status, situation except Exception as e: @@ -1755,7 +1857,11 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: score_x = (self.display_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') score_y = self.display_height - 14 + self._get_layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + draw_overlay, + score_text, + (score_x, score_y), + self.fonts["score"], + fill=self._recent_score_color(game, (255, 255, 255)), ) # "Final" text (Top center) with layout offsets diff --git a/plugins/nrl-scoreboard/README.md b/plugins/nrl-scoreboard/README.md index 79daaf9f..e1e9077b 100644 --- a/plugins/nrl-scoreboard/README.md +++ b/plugins/nrl-scoreboard/README.md @@ -108,6 +108,37 @@ options (see `config_schema.json` for the full list, types, and defaults): timezone; if that isn't set, the host system's timezone is used, and only if neither is available do times fall back to UTC. +## Favorite Team Result Colors + +A run of games against the same opponent is hard to read at a glance: in scroll +and Vegas mode the same two logos go past several times and only the digits +change. Turn on **Customization -> Favorite Team Result Colors** to color a +finished game's score by how your favorite team did - green for a win, red for +a loss. + +```json +{ + "customization": { + "favorite_result_colors": { + "enabled": true, + "win_color": [0, 255, 0], + "loss_color": [255, 0, 0], + "tie_color": [255, 200, 0] + } + } +} +``` + +- Off by default. Until you enable it the score keeps exactly the color it has + today. +- Only finished games are colored. Live and upcoming cards are untouched. +- A game needs exactly one favorite team. If neither side is a favorite, or both + are, the score keeps its normal color. +- Applies to both the one-game-at-a-time switch view and the scroll/Vegas + ticker. +- The three colors are Advanced settings; leave them alone for the defaults + above. + ## License See `LICENSE`. diff --git a/plugins/nrl-scoreboard/config_schema.json b/plugins/nrl-scoreboard/config_schema.json index c081eb25..386a6a7d 100644 --- a/plugins/nrl-scoreboard/config_schema.json +++ b/plugins/nrl-scoreboard/config_schema.json @@ -519,8 +519,436 @@ } }, "additionalProperties": false + }, + "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": { + "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": { + "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, clock, and time remaining text", + "properties": { + "font": { + "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": { + "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": { + "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": { + "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": { + "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": { + "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": { + "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": { + "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": { + "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": { + "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": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "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": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "score": { + "type": "object", + "title": "Game Score", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from center (default: 0)" + } + }, + "additionalProperties": false + }, + "status_text": { + "type": "object", + "title": "Status/Period Text", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from top (default: 0)" + } + }, + "additionalProperties": false + }, + "date": { + "type": "object", + "title": "Game Date", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "time": { + "type": "object", + "title": "Game Time", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from date position (default: 0)" + } + }, + "additionalProperties": false + }, + "records": { + "type": "object", + "title": "Records/Rankings", + "properties": { + "away_x_offset": { + "type": "integer", + "default": 0, + "description": "Away team record horizontal offset from left (default: 0)" + }, + "home_x_offset": { + "type": "integer", + "default": 0, + "description": "Home team record horizontal offset from right (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from bottom (default: 0)" + } + }, + "additionalProperties": false + } + }, + "x-propertyOrder": [ + "home_logo", + "away_logo", + "score", + "status_text", + "date", + "time", + "records" + ], + "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" @@ -557,368 +985,7 @@ "timezone", "dynamic_duration", "mode_durations", - "background_service" - ], - "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": { - "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": { - "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, clock, and time remaining text", - "properties": { - "font": { - "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": { - "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": { - "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": { - "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": { - "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": { - "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": { - "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": { - "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": { - "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": { - "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": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" - }, - "y_offset": { - "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": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" - }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } - }, - "additionalProperties": false - }, - "score": { - "type": "object", - "title": "Game Score", - "properties": { - "x_offset": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" - }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from center (default: 0)" - } - }, - "additionalProperties": false - }, - "status_text": { - "type": "object", - "title": "Status/Period Text", - "properties": { - "x_offset": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" - }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from top (default: 0)" - } - }, - "additionalProperties": false - }, - "date": { - "type": "object", - "title": "Game Date", - "properties": { - "x_offset": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" - }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from default position (default: 0)" - } - }, - "additionalProperties": false - }, - "time": { - "type": "object", - "title": "Game Time", - "properties": { - "x_offset": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" - }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from date position (default: 0)" - } - }, - "additionalProperties": false - }, - "records": { - "type": "object", - "title": "Records/Rankings", - "properties": { - "away_x_offset": { - "type": "integer", - "default": 0, - "description": "Away team record horizontal offset from left (default: 0)" - }, - "home_x_offset": { - "type": "integer", - "default": 0, - "description": "Home team record horizontal offset from right (default: 0)" - }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from bottom (default: 0)" - } - }, - "additionalProperties": false - } - }, - "x-propertyOrder": [ - "home_logo", - "away_logo", - "score", - "status_text", - "date", - "time", - "records" - ], - "additionalProperties": false - } - }, - "x-propertyOrder": [ - "score_text", - "period_text", - "team_name", - "status_text", - "detail_text", - "rank_text", - "layout" - ], - "additionalProperties": false - } + "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 3459c790..0bb85d9a 100644 --- a/plugins/nrl-scoreboard/game_renderer.py +++ b/plugins/nrl-scoreboard/game_renderer.py @@ -14,7 +14,7 @@ import logging import os from pathlib import Path -from typing import Dict, Any, Optional, Tuple +from typing import Any, ClassVar, Dict, Optional, Tuple from PIL import Image, ImageDraw, ImageFont logger = logging.getLogger(__name__) @@ -278,6 +278,143 @@ def _draw_text_with_outline( draw.text((x + dx, y + dy), text, font=font, fill=outline_color) draw.text((x, y), text, font=font, fill=fill) + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # This is the scroll/Vegas path, and it is where the setting earns its + # keep: a series against the same opponent scrolls past as several + # near-identical cards, so tinting the final score green or red is the + # only quick way to tell a win from a loss. Off by default -- the score + # keeps the color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + def _favorite_teams_for(self, game: Dict[str, Any]) -> list: + """Favorite teams that apply to this game. + + Both sources are used. Games carry the league manager's *resolved* + favorites, which is the only place dynamic groups such as AP_TOP_25 + appear expanded; the config is read as well so an edit takes effect on + already-fetched games, and so hand-built game dicts (tests, other + callers) still work. + """ + favorites = list(game.get("favorite_teams") or []) + league_config = self.config.get(str(game.get("league", "") or "")) + if isinstance(league_config, dict): + favorites += list(league_config.get("favorite_teams") or []) + else: + favorites += list(self.config.get("favorite_teams") or []) + return favorites + + @staticmethod + def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) + payload shapes, and matches on the ESPN id too, because a couple of + leagues (NRL) key favorites by id where abbreviations collide. + """ + candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] + team = game.get(f"{side}_team") + if isinstance(team, dict): + candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] + for value in candidates: + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + @staticmethod + def _side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """Numeric score for one side, from either payload shape.""" + raw = None + team = game.get(f"{side}_team") + if isinstance(team, dict) and team.get("score") is not None: + raw = team.get("score") + if raw is None: + raw = game.get(f"{side}_score") + try: + return int(float(str(raw).strip())) + except (TypeError, ValueError): + return None + + def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = { + str(team).strip().upper() + for team in self._favorite_teams_for(game) + if str(team).strip() + } + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + home_score = self._side_score(game, "home") + away_score = self._side_score(game, "away") + if home_score is None or away_score is None: + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _score_color_for(self, game: Dict[str, Any], game_type: str, default=(255, 255, 255)): + """Fill color for a game card's score. Only finished games are tinted.""" + if game_type != "recent": + return default + return self._recent_score_color(game, default) + + def _recent_score_color(self, game: Dict[str, Any], default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def render_game_card( self, game: Dict[str, Any], @@ -348,7 +485,10 @@ def render_game_card( # 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']) + 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 period/status based on game type if game_type == "live": diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index ccd5f6d0..0d3a046f 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.3.2", + "version": "1.4.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.4.0", + "released": "2026-08-05", + "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.3.2", "released": "2026-08-05", diff --git a/plugins/nrl-scoreboard/sports.py b/plugins/nrl-scoreboard/sports.py index 1bd22953..d93f08bc 100644 --- a/plugins/nrl-scoreboard/sports.py +++ b/plugins/nrl-scoreboard/sports.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional, Tuple import pytz import requests @@ -495,6 +495,103 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: self.logger.debug(f"Error getting layout offset for {element}.{axis}: {e}, using default {default}") return default + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # In scroll and Vegas modes the same two logos cycle past over and over -- + # a four-game series against a division rival is four near-identical cards + # -- and picking out which side is yours from the digits alone is the whole + # problem. Tinting the final score by how the favorite did makes it + # readable at a glance. Off by default, so an existing install keeps the + # score color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + @staticmethod + def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Both the abbreviation and the ESPN id are checked, because a couple of + leagues (NRL) match favorites by id where abbreviations collide. + """ + for key in (f"{side}_abbr", f"{side}_id"): + value = game.get(key) + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + def _favorite_result(self, game: Dict) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = getattr(self, "favorite_teams", None) or [] + favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + try: + # int(float(...)) to match GameRenderer._side_score exactly -- the + # two paths must agree on what counts as a usable score. + home_score = int(float(str(game.get("home_score", "")).strip())) + away_score = int(float(str(game.get("away_score", "")).strip())) + except (TypeError, ValueError): + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _recent_score_color(self, game: Dict, default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -1083,6 +1180,11 @@ def extract_logo_url(team_data): / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), "away_logo_url": away_logo_url, "is_within_window": True, # Whether game is within display window + # The resolved favorites for this league (dynamic groups such + # as AP_TOP_25 already expanded). Carried on the game so the + # scroll/Vegas renderer, which only ever sees the game dict and + # the raw config, can color a final score by the result. + "favorite_teams": list(self.favorite_teams or []), } return details, home_team, away_team, status, situation except Exception as e: @@ -2112,7 +2214,11 @@ def format_score(score): # date fits on the bottom line without colliding with the score. score_y = (display_height // 2) - 3 + self._get_layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + draw_overlay, + score_text, + (score_x, score_y), + self.fonts["score"], + fill=self._recent_score_color(game, (255, 255, 255)), ) # "Final" text (Top center) with layout offsets diff --git a/plugins/nrl-scoreboard/test_nrl_plugin.py b/plugins/nrl-scoreboard/test_nrl_plugin.py index 184a028b..14808d07 100644 --- a/plugins/nrl-scoreboard/test_nrl_plugin.py +++ b/plugins/nrl-scoreboard/test_nrl_plugin.py @@ -62,8 +62,11 @@ def test_schema_parses_and_core_fields(self): "background_service", ): self.assertIn(key, props, f"missing schema field: {key}") - # customization block is a root-level sibling (parity with soccer) - self.assertIn("customization", s) + # The customization block used to sit at the schema root, a sibling of + # `properties` rather than one of them, so the web UI never rendered it + # and a config that set it tripped the root's additionalProperties. + self.assertNotIn("customization", s) + self.assertIn("customization", props) # display_modes toggles use live/recent/upcoming (not nrl_-prefixed) dm = props["display_modes"]["properties"] for key in ("live", "recent", "upcoming", diff --git a/plugins/soccer-scoreboard/README.md b/plugins/soccer-scoreboard/README.md index 4259310c..5a9c37ca 100644 --- a/plugins/soccer-scoreboard/README.md +++ b/plugins/soccer-scoreboard/README.md @@ -23,6 +23,7 @@ A plugin for LEDMatrix that displays live, recent, and upcoming soccer games acr - **Upcoming Games**: Scheduled games with start times - **Favorite Teams**: Prioritize games involving your favorite teams - **Background Data Fetching**: Efficient API calls without blocking display +- **Favorite Team Result Colors**: Optionally show a finished game's score in green when your favorite team won and red when it lost ## Configuration @@ -320,6 +321,37 @@ Manual install: copy this directory into your LEDMatrix `plugins_directory` (default `plugin-repos/`) and restart the display service. +## Favorite Team Result Colors + +A run of games against the same opponent is hard to read at a glance: in scroll +and Vegas mode the same two logos go past several times and only the digits +change. Turn on **Customization -> Favorite Team Result Colors** to color a +finished game's score by how your favorite team did - green for a win, red for +a loss. + +```json +{ + "customization": { + "favorite_result_colors": { + "enabled": true, + "win_color": [0, 255, 0], + "loss_color": [255, 0, 0], + "tie_color": [255, 200, 0] + } + } +} +``` + +- Off by default. Until you enable it the score keeps exactly the color it has + today. +- Only finished games are colored. Live and upcoming cards are untouched. +- A game needs exactly one favorite team. If neither side is a favorite, or both + are, the score keeps its normal color. +- Applies to both the one-game-at-a-time switch view and the scroll/Vegas + ticker. +- The three colors are Advanced settings; leave them alone for the defaults + above. + ## Troubleshooting - **Start times look like UTC** (a 6:45pm Central start showing as 11:45PM): diff --git a/plugins/soccer-scoreboard/config_schema.json b/plugins/soccer-scoreboard/config_schema.json index 1f0e95d4..b36b9676 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -4563,370 +4563,436 @@ ], "additionalProperties": false } - } - }, - "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": { - "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" + }, + "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": { + "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": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 10 + } }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 10 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "period_text": { - "type": "object", - "title": "Period/Clock", - "description": "Font settings for period, clock, and time remaining text", - "properties": { - "font": { - "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, clock, and time remaining text", + "properties": { + "font": { + "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": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 8 + } }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 8 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "team_name": { - "type": "object", - "title": "Team Names", - "description": "Font settings for team name abbreviations", - "properties": { - "font": { - "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": { + "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": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 8 + } }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 8 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "status_text": { - "type": "object", - "title": "Status Messages", - "description": "Font settings for status text (e.g., 'Next Game', 'Final')", - "properties": { - "font": { - "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": { + "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": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 6 + } }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 6 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "detail_text": { - "type": "object", - "title": "Details/Odds", - "description": "Font settings for odds and other detail information", - "properties": { - "font": { - "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": { + "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": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 6 + } }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 6 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "rank_text": { - "type": "object", - "title": "Rankings", - "description": "Font settings for ranking displays", - "properties": { - "font": { - "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": { + "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": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 10 + } }, - "font_size": { - "type": "integer", - "title": "Font Size", - "description": "Font size in pixels", - "minimum": 4, - "maximum": 16, - "default": 10 - } + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false }, - "x-propertyOrder": [ - "font", - "font_size" - ], - "additionalProperties": false - }, - "layout": { - "type": "object", - "title": "Layout Positioning", - "description": "Adjust X,Y coordinate offsets for elements. Values are relative to default positions. Use negative values to move left/up, positive to move right/down.", - "properties": { - "home_logo": { - "type": "object", - "title": "Home Team Logo", - "properties": { - "x_offset": { - "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": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } }, - "y_offset": { - "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": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from default position (default: 0)" + "away_logo": { + "type": "object", + "title": "Away Team Logo", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } }, - "y_offset": { - "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": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" + "score": { + "type": "object", + "title": "Game Score", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from center (default: 0)" + } }, - "y_offset": { - "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": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" + "status_text": { + "type": "object", + "title": "Status/Period Text", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from top (default: 0)" + } }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from top (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false - }, - "date": { - "type": "object", - "title": "Game Date", - "properties": { - "x_offset": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" + "date": { + "type": "object", + "title": "Game Date", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } }, - "y_offset": { - "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": { - "type": "integer", - "default": 0, - "description": "Horizontal offset from center (default: 0)" + "time": { + "type": "object", + "title": "Game Time", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from date position (default: 0)" + } }, - "y_offset": { - "type": "integer", - "default": 0, - "description": "Vertical offset from date position (default: 0)" - } + "additionalProperties": false }, - "additionalProperties": false + "records": { + "type": "object", + "title": "Records/Rankings", + "properties": { + "away_x_offset": { + "type": "integer", + "default": 0, + "description": "Away team record horizontal offset from left (default: 0)" + }, + "home_x_offset": { + "type": "integer", + "default": 0, + "description": "Home team record horizontal offset from right (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from bottom (default: 0)" + } + }, + "additionalProperties": false + } }, - "records": { - "type": "object", - "title": "Records/Rankings", - "properties": { - "away_x_offset": { + "x-propertyOrder": [ + "home_logo", + "away_logo", + "score", + "status_text", + "date", + "time", + "records" + ], + "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": { + "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": { - "type": "integer", - "default": 0, - "description": "Vertical offset from bottom (default: 0)" - } + "minItems": 3, + "maxItems": 3, + "default": [255, 0, 0], + "x-advanced": true }, - "additionalProperties": false - } - }, - "x-propertyOrder": [ - "home_logo", - "away_logo", - "score", - "status_text", - "date", - "time", - "records" - ], - "additionalProperties": false - } - }, - "x-propertyOrder": [ - "score_text", - "period_text", - "team_name", - "status_text", - "detail_text", - "rank_text", - "layout" - ], - "additionalProperties": false - }, + "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" @@ -4949,6 +5015,7 @@ "timezone", "background_service", "custom_leagues", - "leagues" + "leagues", + "customization" ] } diff --git a/plugins/soccer-scoreboard/game_renderer.py b/plugins/soccer-scoreboard/game_renderer.py index 16820355..eebf4857 100644 --- a/plugins/soccer-scoreboard/game_renderer.py +++ b/plugins/soccer-scoreboard/game_renderer.py @@ -14,7 +14,7 @@ import logging import os from pathlib import Path -from typing import Dict, Any, Optional, Tuple +from typing import Any, ClassVar, Dict, Optional, Tuple from PIL import Image, ImageDraw, ImageFont logger = logging.getLogger(__name__) @@ -278,6 +278,143 @@ def _draw_text_with_outline( draw.text((x + dx, y + dy), text, font=font, fill=outline_color) draw.text((x, y), text, font=font, fill=fill) + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # This is the scroll/Vegas path, and it is where the setting earns its + # keep: a series against the same opponent scrolls past as several + # near-identical cards, so tinting the final score green or red is the + # only quick way to tell a win from a loss. Off by default -- the score + # keeps the color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + def _favorite_teams_for(self, game: Dict[str, Any]) -> list: + """Favorite teams that apply to this game. + + Both sources are used. Games carry the league manager's *resolved* + favorites, which is the only place dynamic groups such as AP_TOP_25 + appear expanded; the config is read as well so an edit takes effect on + already-fetched games, and so hand-built game dicts (tests, other + callers) still work. + """ + favorites = list(game.get("favorite_teams") or []) + league_config = self.config.get(str(game.get("league", "") or "")) + if isinstance(league_config, dict): + favorites += list(league_config.get("favorite_teams") or []) + else: + favorites += list(self.config.get("favorite_teams") or []) + return favorites + + @staticmethod + def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) + payload shapes, and matches on the ESPN id too, because a couple of + leagues (NRL) key favorites by id where abbreviations collide. + """ + candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] + team = game.get(f"{side}_team") + if isinstance(team, dict): + candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] + for value in candidates: + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + @staticmethod + def _side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """Numeric score for one side, from either payload shape.""" + raw = None + team = game.get(f"{side}_team") + if isinstance(team, dict) and team.get("score") is not None: + raw = team.get("score") + if raw is None: + raw = game.get(f"{side}_score") + try: + return int(float(str(raw).strip())) + except (TypeError, ValueError): + return None + + def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = { + str(team).strip().upper() + for team in self._favorite_teams_for(game) + if str(team).strip() + } + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + home_score = self._side_score(game, "home") + away_score = self._side_score(game, "away") + if home_score is None or away_score is None: + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _score_color_for(self, game: Dict[str, Any], game_type: str, default=(255, 255, 255)): + """Fill color for a game card's score. Only finished games are tinted.""" + if game_type != "recent": + return default + return self._recent_score_color(game, default) + + def _recent_score_color(self, game: Dict[str, Any], default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def render_game_card( self, game: Dict[str, Any], @@ -348,7 +485,10 @@ def render_game_card( # 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']) + 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 period/status based on game type if game_type == "live": diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 93e128b9..a27e4627 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.6.2", + "version": "2.7.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.7.0", + "released": "2026-08-05", + "notes": "Add customization.favorite_result_colors: an optional setting that colors a recent game's final score green when your favorite team won and red when it lost. Aimed at scroll and Vegas modes, where the same matchup can go past several times and the logos alone do not say who won. Off by default; games without exactly one favorite team keep the normal score color.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.6.2", "released": "2026-08-05", diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index 87e9e179..579406cf 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional, Tuple import pytz import requests @@ -477,6 +477,103 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: self.logger.debug(f"Error getting layout offset for {element}.{axis}: {e}, using default {default}") return default + # ------------------------------------------------------------------ + # Favorite-team result colors for finished games. + # + # In scroll and Vegas modes the same two logos cycle past over and over -- + # a four-game series against a division rival is four near-identical cards + # -- and picking out which side is yours from the digits alone is the whole + # problem. Tinting the final score by how the favorite did makes it + # readable at a glance. Off by default, so an existing install keeps the + # score color it has today until the user opts in. + # ------------------------------------------------------------------ + + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), + } + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + @staticmethod + def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Both the abbreviation and the ESPN id are checked, because a couple of + leagues (NRL) match favorites by id where abbreviations collide. + """ + for key in (f"{side}_abbr", f"{side}_id"): + value = game.get(key) + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + def _favorite_result(self, game: Dict) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = getattr(self, "favorite_teams", None) or [] + favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + try: + # int(float(...)) to match GameRenderer._side_score exactly -- the + # two paths must agree on what counts as a usable score. + home_score = int(float(str(game.get("home_score", "")).strip())) + away_score = int(float(str(game.get("away_score", "")).strip())) + except (TypeError, ValueError): + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _recent_score_color(self, game: Dict, default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -1076,6 +1173,11 @@ def extract_logo_url(team_data): / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), "away_logo_url": away_logo_url, "is_within_window": True, # Whether game is within display window + # The resolved favorites for this league (dynamic groups such + # as AP_TOP_25 already expanded). Carried on the game so the + # scroll/Vegas renderer, which only ever sees the game dict and + # the raw config, can color a final score by the result. + "favorite_teams": list(self.favorite_teams or []), } return details, home_team, away_team, status, situation except Exception as e: @@ -2107,7 +2209,11 @@ def format_score(score): # date fits on the bottom line without colliding with the score. score_y = (display_height // 2) - 3 + self._get_layout_offset('score', 'y_offset') self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + draw_overlay, + score_text, + (score_x, score_y), + self.fonts["score"], + fill=self._recent_score_color(game, (255, 255, 255)), ) # "Final" text (Top center) with layout offsets