diff --git a/plugins.json b/plugins.json index 0b6edb1..c68e944 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-02-23", + "last_updated": "2026-02-25", "plugins": [ { "id": "hello-world", @@ -196,10 +196,10 @@ "plugin_path": "plugins/hockey-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-02-15", + "last_updated": "2026-02-25", "verified": true, "screenshot": "", - "latest_version": "1.1.1" + "latest_version": "1.2.4" }, { "id": "football-scoreboard", @@ -221,10 +221,10 @@ "plugin_path": "plugins/football-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-02-20", + "last_updated": "2026-02-25", "verified": true, "screenshot": "", - "latest_version": "2.2.0" + "latest_version": "2.3.4" }, { "id": "ufc-scoreboard", @@ -245,10 +245,10 @@ "plugin_path": "plugins/ufc-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-02-15", + "last_updated": "2026-02-25", "verified": true, "screenshot": "", - "latest_version": "1.1.1" + "latest_version": "1.2.3" }, { "id": "basketball-scoreboard", @@ -270,10 +270,10 @@ "plugin_path": "plugins/basketball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-02-20", + "last_updated": "2026-02-25", "verified": true, "screenshot": "", - "latest_version": "1.4.0" + "latest_version": "1.5.4" }, { "id": "baseball-scoreboard", @@ -296,10 +296,10 @@ "plugin_path": "plugins/baseball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-02-20", + "last_updated": "2026-02-25", "verified": true, "screenshot": "", - "latest_version": "1.4.0" + "latest_version": "1.5.4" }, { "id": "soccer-scoreboard", @@ -325,10 +325,10 @@ "plugin_path": "plugins/soccer-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-02-15", + "last_updated": "2026-02-25", "verified": true, "screenshot": "", - "latest_version": "1.3.1" + "latest_version": "1.4.4" }, { "id": "odds-ticker", @@ -644,10 +644,10 @@ "plugin_path": "plugins/f1-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-02-18", + "last_updated": "2026-02-25", "verified": true, "screenshot": "", - "latest_version": "1.1.0" + "latest_version": "1.2.2" }, { "id": "web-ui-info", diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index 8f48d77..53f867b 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -125,6 +125,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, @@ -456,6 +463,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, @@ -787,6 +801,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index 9064568..15398e7 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -105,10 +105,13 @@ def _load_and_resize_logo(self, league: str, team_abbrev: str) -> Optional[Image if logo.mode != 'RGBA': logo = logo.convert('RGBA') - # Resize logo to fit display - max_width = int(self.display_width * 1.5) - max_height = int(self.display_height * 1.5) - logo.thumbnail((max_width, max_height), RESAMPLE_FILTER) + # Crop transparent padding then scale so ink fills display_height. + # thumbnail into a display_height square box preserves aspect ratio + # and prevents wide logos from exceeding their half-card slot. + bbox = logo.getbbox() + if bbox: + logo = logo.crop(bbox) + logo.thumbnail((self.display_height, self.display_height), RESAMPLE_FILTER) # Copy before exiting context manager cached_logo = logo.copy() @@ -170,8 +173,11 @@ def _render_live_game(self, game: Dict) -> Image.Image: center_y = self.display_height // 2 # Logos - main_img.paste(home_logo, (self.display_width - home_logo.width, center_y - home_logo.height // 2), home_logo) - main_img.paste(away_logo, (0, center_y - away_logo.height // 2), away_logo) + logo_slot = min(self.display_height, self.display_width // 2) + away_x = (logo_slot - away_logo.width) // 2 + main_img.paste(away_logo, (away_x, center_y - away_logo.height // 2), away_logo) + home_x = (self.display_width - logo_slot) + (logo_slot - home_logo.width) // 2 + main_img.paste(home_logo, (home_x, center_y - home_logo.height // 2), home_logo) # Inning indicator (top center) inning_half = game.get('inning_half', 'top') @@ -311,8 +317,11 @@ def _render_recent_game(self, game: Dict) -> Image.Image: center_y = self.display_height // 2 # Logos (tighter fit for recent) - main_img.paste(home_logo, (self.display_width - home_logo.width, center_y - home_logo.height // 2), home_logo) - main_img.paste(away_logo, (0, center_y - away_logo.height // 2), away_logo) + logo_slot = min(self.display_height, self.display_width // 2) + away_x = (logo_slot - away_logo.width) // 2 + main_img.paste(away_logo, (away_x, center_y - away_logo.height // 2), away_logo) + home_x = (self.display_width - logo_slot) + (logo_slot - home_logo.width) // 2 + main_img.paste(home_logo, (home_x, center_y - home_logo.height // 2), home_logo) # "Final" (top center) status_text = "Final" @@ -357,8 +366,11 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: center_y = self.display_height // 2 # Logos (tighter fit) - main_img.paste(home_logo, (self.display_width - home_logo.width, center_y - home_logo.height // 2), home_logo) - main_img.paste(away_logo, (0, center_y - away_logo.height // 2), away_logo) + logo_slot = min(self.display_height, self.display_width // 2) + away_x = (logo_slot - away_logo.width) // 2 + main_img.paste(away_logo, (away_x, center_y - away_logo.height // 2), away_logo) + home_x = (self.display_width - logo_slot) + (logo_slot - home_logo.width) // 2 + main_img.paste(home_logo, (home_x, center_y - home_logo.height // 2), home_logo) # "Next Game" (top center) status_font = self.fonts['status'] if self.display_width <= 128 else self.fonts['time'] @@ -373,7 +385,7 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: if start_time: try: dt = datetime.fromisoformat(start_time.replace('Z', '+00:00')) - local_tz = pytz.timezone(self.config.get('timezone', 'US/Eastern')) + local_tz = pytz.timezone(self.config.get('timezone') or 'UTC') dt_local = dt.astimezone(local_tz) game_date = dt_local.strftime('%b %d') game_time = dt_local.strftime('%-I:%M %p') diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index af13622..3e96a75 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -93,6 +93,16 @@ def __init__( self.logger = logger + # Resolve timezone: plugin config → global config → UTC. + # Inject into self.config so all sub-components (scroll display, game + # renderer, etc.) can read it via config.get('timezone'). + if not self.config.get("timezone"): + try: + global_tz = cache_manager.config_manager.get_timezone() + except Exception: + global_tz = "UTC" + self.config["timezone"] = global_tz or "UTC" + # Basic configuration self.is_enabled = config.get("enabled", True) # Get display dimensions from display_manager properties diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index c043e32..f2118e5 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.4.0", + "version": "1.5.4", "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,31 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "released": "2026-02-25", + "version": "1.5.4", + "ledmatrix_min": "2.0.0" + }, + { + "released": "2026-02-24", + "version": "1.5.3", + "ledmatrix_min": "2.0.0" + }, + { + "released": "2026-02-24", + "version": "1.5.2", + "ledmatrix_min": "2.0.0" + }, + { + "released": "2026-02-24", + "version": "1.5.1", + "ledmatrix_min": "2.0.0" + }, + { + "released": "2026-02-24", + "version": "1.5.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-02-20", "version": "1.4.0", @@ -76,7 +101,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-02-20", + "last_updated": "2026-02-24", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/baseball-scoreboard/scroll_display.py b/plugins/baseball-scoreboard/scroll_display.py index ad3dbeb..196062a 100644 --- a/plugins/baseball-scoreboard/scroll_display.py +++ b/plugins/baseball-scoreboard/scroll_display.py @@ -185,7 +185,8 @@ def _get_scroll_settings(self, league: Optional[str] = None) -> Dict[str, Any]: "scroll_delay": 0.01, "gap_between_games": 48, "show_league_separators": True, - "dynamic_duration": True + "dynamic_duration": True, + "game_card_width": 128, } # Try to get league-specific settings first @@ -215,15 +216,21 @@ def _get_scroll_settings(self, league: Optional[str] = None) -> Dict[str, Any]: return defaults - def _get_game_renderer(self) -> Optional[GameRenderer]: - """Get or create the cached GameRenderer instance.""" + def _get_game_renderer(self, game_card_width: int = 128) -> Optional[GameRenderer]: + """Get or create the cached GameRenderer instance. + + Args: + game_card_width: Width for each game card. Cached renderer is recreated + if this differs from the current renderer's width. + """ if GameRenderer is None: self.logger.error("GameRenderer not available") return None - if self._game_renderer is None: + # Recreate renderer if card width changed (e.g. config update) + if self._game_renderer is None or getattr(self._game_renderer, "display_width", None) != game_card_width: self._game_renderer = GameRenderer( - self.display_width, + game_card_width, self.display_height, self.config, logo_cache=self._logo_cache, @@ -329,9 +336,11 @@ def prepare_scroll_content( scroll_settings = self._get_scroll_settings() gap_between_games = scroll_settings.get("gap_between_games", 24) show_separators = scroll_settings.get("show_league_separators", True) + game_card_width = scroll_settings.get("game_card_width", 128) - # Get or create cached game renderer - renderer = self._get_game_renderer() + # Get or create cached game renderer using game_card_width so cards are a fixed + # size regardless of the full chain width (display_width may span multiple panels) + renderer = self._get_game_renderer(game_card_width) # Pass rankings cache to renderer if available if renderer and rankings_cache: diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py index efb8907..66921b7 100644 --- a/plugins/baseball-scoreboard/sports.py +++ b/plugins/baseball-scoreboard/sports.py @@ -848,14 +848,20 @@ def _fetch_data(self) -> Optional[Dict]: def _fetch_todays_games(self) -> Optional[Dict]: """Fetch only today's games for live updates (not entire season).""" try: - now = datetime.now() + # ESPN API anchors its schedule calendar to Eastern US time. + # Always query using the Eastern date + 1-day lookback to catch + # late-night games still in progress from the previous Eastern day. + tz = pytz.timezone("America/New_York") + now = datetime.now(tz) + yesterday = now - timedelta(days=1) formatted_date = now.strftime("%Y%m%d") + formatted_date_yesterday = yesterday.strftime("%Y%m%d") # Fetch todays games only url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" self.logger.debug(f"Fetching today's games for {self.sport}/{self.league} on date {formatted_date}") response = self.session.get( url, - params={"dates": formatted_date, "limit": 1000}, + params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000}, headers=self.headers, timeout=10, ) diff --git a/plugins/basketball-scoreboard/config_schema.json b/plugins/basketball-scoreboard/config_schema.json index 3dcd341..e752e39 100644 --- a/plugins/basketball-scoreboard/config_schema.json +++ b/plugins/basketball-scoreboard/config_schema.json @@ -151,6 +151,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, @@ -463,6 +470,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, @@ -775,6 +789,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, @@ -1120,6 +1141,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, diff --git a/plugins/basketball-scoreboard/game_renderer.py b/plugins/basketball-scoreboard/game_renderer.py index 04183e5..3820940 100644 --- a/plugins/basketball-scoreboard/game_renderer.py +++ b/plugins/basketball-scoreboard/game_renderer.py @@ -226,10 +226,13 @@ def _load_and_resize_logo( if img.mode != "RGBA": img = img.convert("RGBA") - # Resize to fit display - max_width = int(self.display_width * 1.5) - max_height = int(self.display_height * 1.5) - img.thumbnail((max_width, max_height), resample=RESAMPLE_FILTER) + # Crop transparent padding then scale so ink fills display_height. + # thumbnail into a display_height square box preserves aspect ratio + # and prevents wide logos from exceeding their half-card slot. + bbox = img.getbbox() + if bbox: + img = img.crop(bbox) + img.thumbnail((self.display_height, self.display_height), resample=RESAMPLE_FILTER) # Copy before context manager closes file handle logo = img.copy() @@ -245,9 +248,10 @@ def _load_and_resize_logo( if img.mode != "RGBA": img = img.convert("RGBA") - max_width = int(self.display_width * 1.5) - max_height = int(self.display_height * 1.5) - img.thumbnail((max_width, max_height), resample=RESAMPLE_FILTER) + bbox = img.getbbox() + if bbox: + img = img.crop(bbox) + img.thumbnail((self.display_height, self.display_height), resample=RESAMPLE_FILTER) # Copy before context manager closes file handle logo = img.copy() @@ -334,14 +338,17 @@ def render_game_card( center_y = self.display_height // 2 - # Draw logos - home_x = self.display_width - home_logo.width - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = 0 + # Draw logos — each centered within a slot on its side; cap at half the card + # width so home_slot_start stays non-negative on square/tall displays + logo_slot = min(self.display_height, self.display_width // 2) + away_x = (logo_slot - away_logo.width) // 2 away_y = center_y - (away_logo.height // 2) main_img.paste(away_logo, (away_x, away_y), away_logo) + + home_slot_start = self.display_width - logo_slot + home_x = home_slot_start + (logo_slot - home_logo.width) // 2 + home_y = center_y - (home_logo.height // 2) + main_img.paste(home_logo, (home_x, home_y), home_logo) # Draw scores (centered) home_score = str(game.get("home_score", "0")) diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 62fe4f3..ec8cf7f 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.4.0", + "version": "1.5.4", "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,31 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "released": "2026-02-25", + "version": "1.5.4", + "ledmatrix_min": "2.0.0" + }, + { + "released": "2026-02-24", + "version": "1.5.3", + "ledmatrix_min": "2.0.0" + }, + { + "version": "1.5.2", + "ledmatrix_min": "2.0.0", + "released": "2026-02-24" + }, + { + "version": "1.5.1", + "ledmatrix_min": "2.0.0", + "released": "2026-02-24" + }, + { + "version": "1.5.0", + "ledmatrix_min": "2.0.0", + "released": "2026-02-24" + }, { "version": "1.4.0", "ledmatrix_min": "2.0.0", @@ -46,7 +71,7 @@ ], "stars": 0, "downloads": 0, - "last_updated": "2026-02-20", + "last_updated": "2026-02-24", "verified": true, "screenshot": "", "display_modes": [ diff --git a/plugins/basketball-scoreboard/scroll_display.py b/plugins/basketball-scoreboard/scroll_display.py index 20c3fdd..fb334fe 100644 --- a/plugins/basketball-scoreboard/scroll_display.py +++ b/plugins/basketball-scoreboard/scroll_display.py @@ -179,9 +179,10 @@ def _get_scroll_settings(self, league: str = None) -> Dict[str, Any]: "scroll_delay": 0.01, "gap_between_games": 48, "show_league_separators": True, - "dynamic_duration": True + "dynamic_duration": True, + "game_card_width": 128, } - + # Try to get league-specific settings first if league: league_config = self.config.get(league, {}) @@ -330,15 +331,17 @@ def prepare_scroll_content( scroll_settings = self._get_scroll_settings() gap_between_games = scroll_settings.get("gap_between_games", 24) show_separators = scroll_settings.get("show_league_separators", True) + game_card_width = scroll_settings.get("game_card_width", 128) # Verify GameRenderer is available if GameRenderer is None: self.logger.error("GameRenderer not available - cannot prepare scroll content") return False - # Create game renderer + # Create game renderer using game_card_width so cards are a fixed size + # regardless of the full chain width (display_width may span multiple panels) renderer = GameRenderer( - self.display_width, + game_card_width, self.display_height, self.config, logo_cache=self._logo_cache, diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index 1514131..94d9716 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -1162,9 +1162,15 @@ def _fetch_todays_games(self) -> Optional[Dict]: params = {"limit": 1000} # No dates parameter self.logger.debug(f"Fetching current games for {self.sport}/{self.league} (no dates)") else: - now = datetime.now() + # ESPN API anchors its schedule calendar to Eastern US time. + # Always query using the Eastern date + 1-day lookback to catch + # late-night games still in progress from the previous Eastern day. + tz = pytz.timezone("America/New_York") + now = datetime.now(tz) + yesterday = now - timedelta(days=1) formatted_date = now.strftime("%Y%m%d") - params = {"dates": formatted_date, "limit": 1000} + formatted_date_yesterday = yesterday.strftime("%Y%m%d") + params = {"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000} self.logger.debug(f"Fetching today's games for {self.sport}/{self.league} on date {formatted_date}") response = self.session.get( diff --git a/plugins/f1-scoreboard/config_schema.json b/plugins/f1-scoreboard/config_schema.json index 4b9b2e9..6571dbd 100644 --- a/plugins/f1-scoreboard/config_schema.json +++ b/plugins/f1-scoreboard/config_schema.json @@ -320,6 +320,38 @@ "x-propertyOrder": ["enabled", "max_duration_seconds"], "additionalProperties": false }, + "scroll": { + "type": "object", + "title": "Scroll Settings", + "description": "Settings for scroll display mode", + "properties": { + "game_card_width": { + "type": "integer", + "title": "Card Width", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more cards simultaneously." + }, + "scroll_speed": { + "type": "number", + "title": "Scroll Speed", + "default": 1, + "minimum": 0.01, + "maximum": 10, + "description": "Pixels per frame to scroll (frame-based mode)" + }, + "scroll_delay": { + "type": "number", + "title": "Scroll Delay", + "default": 0.03, + "minimum": 0.001, + "maximum": 0.1, + "description": "Delay between scroll frames in seconds" + } + }, + "additionalProperties": false + }, "customization": { "type": "object", "title": "Display Customization", @@ -418,7 +450,7 @@ "additionalProperties": false } }, - "x-propertyOrder": ["enabled", "display_duration", "update_interval", "favorite_team", "favorite_driver", "driver_standings", "constructor_standings", "recent_races", "upcoming", "qualifying", "practice", "sprint", "calendar", "dynamic_duration", "customization"], + "x-propertyOrder": ["enabled", "display_duration", "update_interval", "favorite_team", "favorite_driver", "driver_standings", "constructor_standings", "recent_races", "upcoming", "qualifying", "practice", "sprint", "calendar", "dynamic_duration", "scroll", "customization"], "additionalProperties": false, "required": ["enabled"] } diff --git a/plugins/f1-scoreboard/f1_renderer.py b/plugins/f1-scoreboard/f1_renderer.py index fc02934..e633abd 100644 --- a/plugins/f1-scoreboard/f1_renderer.py +++ b/plugins/f1-scoreboard/f1_renderer.py @@ -14,6 +14,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Union +import pytz from PIL import Image, ImageDraw, ImageFont from logo_downloader import F1LogoLoader @@ -77,6 +78,16 @@ def _load_fonts(self) -> Dict[str, Any]: return fonts + def _to_local_dt(self, utc_iso_str: str) -> datetime: + """Parse a UTC ISO datetime string and convert to configured local timezone.""" + dt = datetime.fromisoformat(utc_iso_str.replace("Z", "+00:00")) + tz_str = self.config.get("timezone", "UTC") + try: + local_tz = pytz.timezone(tz_str) + except Exception: + local_tz = pytz.UTC + return dt.astimezone(local_tz) + def _load_font(self, font_name: str, size: int) -> Union[ImageFont.FreeTypeFont, Any]: """Load a font with multiple path fallbacks.""" @@ -705,8 +716,7 @@ def render_upcoming_race(self, race: Dict) -> Image.Image: date_prefix = "" if race_date: try: - dt = datetime.fromisoformat( - race_date.replace("Z", "+00:00")) + dt = self._to_local_dt(race_date) date_prefix = dt.strftime("%b %d").upper() + " " except (ValueError, TypeError): pass @@ -739,8 +749,7 @@ def render_calendar_entry(self, entry: Dict) -> Image.Image: day_display = "" if date_str: try: - dt = datetime.fromisoformat( - date_str.replace("Z", "+00:00")) + dt = self._to_local_dt(date_str) date_display = dt.strftime("%b %d").upper() day_display = dt.strftime("%a").upper() except (ValueError, TypeError): diff --git a/plugins/f1-scoreboard/manager.py b/plugins/f1-scoreboard/manager.py index 95f4482..5edc96c 100644 --- a/plugins/f1-scoreboard/manager.py +++ b/plugins/f1-scoreboard/manager.py @@ -54,12 +54,31 @@ def __init__(self, plugin_id, config, display_manager, # Display duration self.display_duration = config.get("display_duration", 30) + # Scroll card width: use a fixed card width for scroll mode so cards are + # properly sized regardless of the full chain width (multi-panel setups) + scroll_cfg = config.get("scroll", {}) if isinstance(config.get("scroll"), dict) else {} + self._card_width = scroll_cfg.get("game_card_width", 128) + + # Resolve timezone: plugin config → global config → UTC. + # Inject into config so the renderer can convert UTC API times to local. + if not config.get("timezone"): + try: + global_tz = cache_manager.config_manager.get_timezone() + except Exception: + global_tz = "UTC" + config["timezone"] = global_tz or "UTC" + # Initialize components self.logo_loader = F1LogoLoader() self.data_source = F1DataSource(cache_manager, config) + # Full-width renderer for static single-card display self.renderer = F1Renderer( self.display_width, self.display_height, config, self.logo_loader, self.logger) + # Card-width renderer for scroll/Vegas mode + self._scroll_renderer = F1Renderer( + self._card_width, self.display_height, + config, self.logo_loader, self.logger) self._scroll_manager = ScrollDisplayManager( display_manager, config, self.logger) @@ -297,25 +316,26 @@ def _update_calendar(self): def _prepare_scroll_content(self): """Pre-render all scroll mode content.""" - separator = self.renderer.render_f1_separator() + r = self._scroll_renderer + separator = r.render_f1_separator() # Driver standings if self._driver_standings: - cards = [self.renderer.render_driver_standing(e) + cards = [r.render_driver_standing(e) for e in self._driver_standings] self._scroll_manager.prepare_and_display( "driver_standings", cards, separator) # Constructor standings if self._constructor_standings: - cards = [self.renderer.render_constructor_standing(e) + cards = [r.render_constructor_standing(e) for e in self._constructor_standings] self._scroll_manager.prepare_and_display( "constructor_standings", cards, separator) # Recent races if self._recent_races: - cards = [self.renderer.render_race_result(race) + cards = [r.render_race_result(race) for race in self._recent_races] self._scroll_manager.prepare_and_display( "recent_races", cards, separator) @@ -335,16 +355,16 @@ def _prepare_scroll_content(self): # Sprint if self._sprint and self._sprint.get("results"): - cards = [self.renderer.render_sprint_header( + cards = [r.render_sprint_header( self._sprint.get("race_name", ""))] for entry in self._sprint["results"]: - cards.append(self.renderer.render_sprint_entry(entry)) + cards.append(r.render_sprint_entry(entry)) self._scroll_manager.prepare_and_display( "sprint", cards, separator) # Calendar if self._calendar: - cards = [self.renderer.render_calendar_entry(e) + cards = [r.render_calendar_entry(e) for e in self._calendar] self._scroll_manager.prepare_and_display( "calendar", cards, separator) @@ -354,6 +374,7 @@ def _build_qualifying_cards(self) -> List[Image.Image]: if not self._qualifying: return [] + r = self._scroll_renderer cards = [] quali_config = self.config.get("qualifying", {}) results = self._qualifying.get("results", []) @@ -368,24 +389,25 @@ def _build_qualifying_cards(self) -> List[Image.Image]: continue # Add session header - cards.append(self.renderer.render_qualifying_header( + cards.append(r.render_qualifying_header( label, race_name)) # Add entries for this session for entry in results: # Only show entries that have a time for this session if entry.get(session_key): - cards.append(self.renderer.render_qualifying_entry( + cards.append(r.render_qualifying_entry( entry, label)) elif entry.get("eliminated_in") == label: # Show eliminated driver - cards.append(self.renderer.render_qualifying_entry( + cards.append(r.render_qualifying_entry( entry, label)) return cards def _build_practice_cards(self) -> List[Image.Image]: """Build practice result cards for all configured sessions.""" + r = self._scroll_renderer cards = [] for fp_key in ["FP3", "FP2", "FP1"]: # Most recent first @@ -393,11 +415,11 @@ def _build_practice_cards(self) -> List[Image.Image]: continue fp_data = self._practice_results[fp_key] - cards.append(self.renderer.render_practice_header( + cards.append(r.render_practice_header( fp_key, fp_data.get("circuit", ""))) for entry in fp_data.get("results", []): - cards.append(self.renderer.render_practice_entry(entry)) + cards.append(r.render_practice_entry(entry)) return cards @@ -513,9 +535,9 @@ def get_vegas_content(self) -> Optional[List[Image.Image]]: images.extend( self._scroll_manager.get_vegas_items_for_mode(mode_key)) - # Add upcoming race card if available + # Add upcoming race card if available (use scroll renderer for consistent card width) if self._upcoming_race: - upcoming_card = self.renderer.render_upcoming_race( + upcoming_card = self._scroll_renderer.render_upcoming_race( self._enrich_upcoming_with_countdown(self._upcoming_race)) images.insert(0, upcoming_card) @@ -607,9 +629,14 @@ def on_config_change(self, new_config): self.modes = self._build_enabled_modes() # Force re-render with new settings + scroll_cfg = new_config.get("scroll", {}) if isinstance(new_config.get("scroll"), dict) else {} + self._card_width = scroll_cfg.get("game_card_width", 128) self.renderer = F1Renderer( self.display_width, self.display_height, new_config, self.logo_loader, self.logger) + self._scroll_renderer = F1Renderer( + self._card_width, self.display_height, + new_config, self.logo_loader, self.logger) self._scroll_manager = ScrollDisplayManager( self.display_manager, new_config, self.logger) diff --git a/plugins/f1-scoreboard/manifest.json b/plugins/f1-scoreboard/manifest.json index 541ae01..2ec8c4d 100644 --- a/plugins/f1-scoreboard/manifest.json +++ b/plugins/f1-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "f1-scoreboard", "name": "F1 Scoreboard", - "version": "1.1.0", + "version": "1.2.2", "author": "ChuckBuilds", "class_name": "F1ScoreboardPlugin", "entry_point": "manager.py", @@ -10,7 +10,14 @@ "plugin_path": "plugins/f1-scoreboard", "description": "Formula 1 racing plugin showing driver/constructor standings, race results, qualifying breakdowns, practice standings, sprint results, upcoming races, and race calendar with team-colored displays and favorite driver/team support", "category": "sports", - "tags": ["f1", "formula1", "racing", "motorsport", "sports", "scoreboard"], + "tags": [ + "f1", + "formula1", + "racing", + "motorsport", + "sports", + "scoreboard" + ], "display_modes": [ "f1_driver_standings", "f1_constructor_standings", @@ -22,6 +29,21 @@ "f1_calendar" ], "versions": [ + { + "released": "2026-02-25", + "version": "1.2.2", + "ledmatrix_min": "2.0.0" + }, + { + "version": "1.2.1", + "ledmatrix_min": "2.0.0", + "released": "2026-02-24" + }, + { + "version": "1.2.0", + "ledmatrix_min": "2.0.0", + "released": "2026-02-24" + }, { "version": "1.1.0", "ledmatrix_min": "2.0.0", diff --git a/plugins/football-scoreboard/config_schema.json b/plugins/football-scoreboard/config_schema.json index 591909b..6c06503 100644 --- a/plugins/football-scoreboard/config_schema.json +++ b/plugins/football-scoreboard/config_schema.json @@ -123,6 +123,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, @@ -408,6 +415,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, diff --git a/plugins/football-scoreboard/game_renderer.py b/plugins/football-scoreboard/game_renderer.py index 0b4e2af..0ca2c4f 100644 --- a/plugins/football-scoreboard/game_renderer.py +++ b/plugins/football-scoreboard/game_renderer.py @@ -236,11 +236,14 @@ def _load_and_resize_logo( if logo.mode != "RGBA": logo = logo.convert("RGBA") - # Resize to fit display - max_width = int(self.display_width * 1.5) - max_height = int(self.display_height * 1.5) - logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - + # Crop transparent padding then scale so ink fills display_height. + # thumbnail into a display_height square box preserves aspect ratio + # and prevents wide logos from exceeding their half-card slot. + bbox = logo.getbbox() + if bbox: + logo = logo.crop(bbox) + logo.thumbnail((self.display_height, self.display_height), Image.Resampling.LANCZOS) + self._logo_cache[team_abbrev] = logo return logo else: @@ -325,14 +328,17 @@ def render_game_card( center_y = self.display_height // 2 - # Draw logos - home_x = self.display_width - home_logo.width - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = 0 + # Draw logos — each centered within a slot on its side; cap at half the card + # width so home_slot_start stays non-negative on square/tall displays + logo_slot = min(self.display_height, self.display_width // 2) + away_x = (logo_slot - away_logo.width) // 2 away_y = center_y - (away_logo.height // 2) main_img.paste(away_logo, (away_x, away_y), away_logo) + + home_slot_start = self.display_width - logo_slot + home_x = home_slot_start + (logo_slot - home_logo.width) // 2 + home_y = center_y - (home_logo.height // 2) + main_img.paste(home_logo, (home_x, home_y), home_logo) # Draw scores (centered) home_score = str(game.get("home_score", "0")) diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index be94ac8..5906421 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.2.0", + "version": "2.3.4", "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,31 @@ "ncaa_fb_live" ], "versions": [ + { + "released": "2026-02-25", + "version": "2.3.4", + "ledmatrix_min": "2.0.0" + }, + { + "released": "2026-02-24", + "version": "2.3.3", + "ledmatrix_min": "2.0.0" + }, + { + "version": "2.3.2", + "ledmatrix_min": "2.0.0", + "released": "2026-02-24" + }, + { + "version": "2.3.1", + "ledmatrix_min": "2.0.0", + "released": "2026-02-24" + }, + { + "version": "2.3.0", + "ledmatrix_min": "2.0.0", + "released": "2026-02-24" + }, { "version": "2.2.0", "ledmatrix_min": "2.0.0", @@ -215,7 +240,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-02-20", + "last_updated": "2026-02-24", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/football-scoreboard/scroll_display.py b/plugins/football-scoreboard/scroll_display.py index becaa9f..8b287b4 100644 --- a/plugins/football-scoreboard/scroll_display.py +++ b/plugins/football-scoreboard/scroll_display.py @@ -167,9 +167,10 @@ def _get_scroll_settings(self, league: str = None) -> Dict[str, Any]: "scroll_delay": 0.01, "gap_between_games": 48, "show_league_separators": True, - "dynamic_duration": True + "dynamic_duration": True, + "game_card_width": 128, } - + # Try to get league-specific settings first if league: league_config = self.config.get(league, {}) @@ -291,10 +292,12 @@ def prepare_scroll_content( scroll_settings = self._get_scroll_settings() gap_between_games = scroll_settings.get("gap_between_games", 24) show_separators = scroll_settings.get("show_league_separators", True) + game_card_width = scroll_settings.get("game_card_width", 128) - # Create game renderer + # Create game renderer using game_card_width so cards are a fixed size + # regardless of the full chain width (display_width may span multiple panels) renderer = GameRenderer( - self.display_width, + game_card_width, self.display_height, self.config, logo_cache=self._logo_cache, diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index bd2b301..921c868 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -848,14 +848,20 @@ def _fetch_data(self) -> Optional[Dict]: def _fetch_todays_games(self) -> Optional[Dict]: """Fetch only today's games for live updates (not entire season).""" try: - now = datetime.now() + # ESPN API anchors its schedule calendar to Eastern US time. + # Always query using the Eastern date + 1-day lookback to catch + # late-night games still in progress from the previous Eastern day. + tz = pytz.timezone("America/New_York") + now = datetime.now(tz) + yesterday = now - timedelta(days=1) formatted_date = now.strftime("%Y%m%d") + formatted_date_yesterday = yesterday.strftime("%Y%m%d") # Fetch todays games only url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" self.logger.debug(f"Fetching today's games for {self.sport}/{self.league} on date {formatted_date}") response = self.session.get( url, - params={"dates": formatted_date, "limit": 1000}, + params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000}, headers=self.headers, timeout=10, ) diff --git a/plugins/hockey-scoreboard/config_schema.json b/plugins/hockey-scoreboard/config_schema.json index a7f09a8..4fbe444 100644 --- a/plugins/hockey-scoreboard/config_schema.json +++ b/plugins/hockey-scoreboard/config_schema.json @@ -145,6 +145,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, @@ -486,6 +493,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, @@ -827,6 +841,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, diff --git a/plugins/hockey-scoreboard/game_renderer.py b/plugins/hockey-scoreboard/game_renderer.py index d3a7d46..e465bf1 100644 --- a/plugins/hockey-scoreboard/game_renderer.py +++ b/plugins/hockey-scoreboard/game_renderer.py @@ -209,10 +209,13 @@ def _load_and_resize_logo( else: logo = logo_file.copy() - # Resize to fit display (after context manager closes file) - max_width = int(self.display_width * 1.5) - max_height = int(self.display_height * 1.5) - logo.thumbnail((max_width, max_height), RESAMPLE_FILTER) + # Crop transparent padding then scale so ink fills display_height. + # thumbnail into a display_height square box preserves aspect ratio + # and prevents wide logos from exceeding their half-card slot. + bbox = logo.getbbox() + if bbox: + logo = logo.crop(bbox) + logo.thumbnail((self.display_height, self.display_height), RESAMPLE_FILTER) self._logo_cache[cache_key] = logo return logo @@ -364,15 +367,18 @@ def render_game_card( center_y = self.display_height // 2 - # Draw logos (flush with card edges to avoid clipping in scroll/Vegas mode) - home_x = self.display_width - home_logo.width - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = 0 + # Draw logos — each centered within a slot on its side; cap at half the card + # width so home_slot_start stays non-negative on square/tall displays + logo_slot = min(self.display_height, self.display_width // 2) + away_x = (logo_slot - away_logo.width) // 2 away_y = center_y - (away_logo.height // 2) main_img.paste(away_logo, (away_x, away_y), away_logo) + home_slot_start = self.display_width - logo_slot + home_x = home_slot_start + (logo_slot - home_logo.width) // 2 + home_y = center_y - (home_logo.height // 2) + main_img.paste(home_logo, (home_x, home_y), home_logo) + # Draw scores (centered) - only for live and recent games if game_type in ("live", "recent"): home_score = str(home_team.get("score", "0")) diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 1dd3b58..c8099fb 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.1.1", + "version": "1.2.4", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -55,6 +55,31 @@ } ], "versions": [ + { + "released": "2026-02-25", + "version": "1.2.4", + "ledmatrix_min": "2.0.0" + }, + { + "released": "2026-02-24", + "version": "1.2.3", + "ledmatrix_min": "2.0.0" + }, + { + "version": "1.2.2", + "ledmatrix_min": "2.0.0", + "released": "2026-02-24" + }, + { + "version": "1.2.1", + "ledmatrix_min": "2.0.0", + "released": "2026-02-24" + }, + { + "version": "1.2.0", + "ledmatrix_min": "2.0.0", + "released": "2026-02-24" + }, { "version": "1.1.1", "ledmatrix_min": "2.0.0", @@ -106,7 +131,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-02-15", + "last_updated": "2026-02-24", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/hockey-scoreboard/scroll_display.py b/plugins/hockey-scoreboard/scroll_display.py index 5a49291..5490f39 100644 --- a/plugins/hockey-scoreboard/scroll_display.py +++ b/plugins/hockey-scoreboard/scroll_display.py @@ -172,7 +172,8 @@ def _get_scroll_settings(self, league: Optional[str] = None) -> Dict[str, Any]: "scroll_delay": 0.01, "gap_between_games": 48, "show_league_separators": True, - "dynamic_duration": True + "dynamic_duration": True, + "game_card_width": 128, } # Try to get league-specific settings first @@ -314,10 +315,12 @@ def prepare_scroll_content( scroll_settings = self._get_scroll_settings(primary_league) gap_between_games = scroll_settings.get("gap_between_games", 24) show_separators = scroll_settings.get("show_league_separators", True) + game_card_width = scroll_settings.get("game_card_width", 128) - # Create game renderer + # Create game renderer using game_card_width so cards are a fixed size + # regardless of the full chain width (display_width may span multiple panels) renderer = GameRenderer( - self.display_width, + game_card_width, self.display_height, self.config, logo_cache=self._logo_cache, diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index 6f6bfdc..9dd8730 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -833,13 +833,19 @@ def _fetch_data(self) -> Optional[Dict]: def _fetch_todays_games(self) -> Optional[Dict]: """Fetch only today's games for live updates (not entire season).""" try: - now = datetime.now() + # ESPN API anchors its schedule calendar to Eastern US time. + # Always query using the Eastern date + 1-day lookback to catch + # late-night games still in progress from the previous Eastern day. + tz = pytz.timezone("America/New_York") + now = datetime.now(tz) + yesterday = now - timedelta(days=1) formatted_date = now.strftime("%Y%m%d") + formatted_date_yesterday = yesterday.strftime("%Y%m%d") # Fetch todays games only url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" response = self.session.get( url, - params={"dates": formatted_date, "limit": 1000}, + params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000}, headers=self.headers, timeout=10, ) diff --git a/plugins/soccer-scoreboard/config_schema.json b/plugins/soccer-scoreboard/config_schema.json index be3dc8d..2144aae 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -237,6 +237,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, @@ -563,6 +570,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, @@ -889,6 +903,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, @@ -1215,6 +1236,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, @@ -1541,6 +1569,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, @@ -1867,6 +1902,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, @@ -2193,6 +2235,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, @@ -2519,6 +2568,13 @@ "type": "boolean", "default": true, "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." } } }, diff --git a/plugins/soccer-scoreboard/game_renderer.py b/plugins/soccer-scoreboard/game_renderer.py index 4fa2fdb..1bfe513 100644 --- a/plugins/soccer-scoreboard/game_renderer.py +++ b/plugins/soccer-scoreboard/game_renderer.py @@ -180,11 +180,14 @@ def _load_and_resize_logo( if logo.mode != "RGBA": logo = logo.convert("RGBA") - # Resize to fit display - max_width = int(self.display_width * 1.5) - max_height = int(self.display_height * 1.5) - logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - + # Crop transparent padding then scale so ink fills display_height. + # thumbnail into a display_height square box preserves aspect ratio + # and prevents wide logos from exceeding their half-card slot. + bbox = logo.getbbox() + if bbox: + logo = logo.crop(bbox) + logo.thumbnail((self.display_height, self.display_height), Image.Resampling.LANCZOS) + self._logo_cache[team_abbrev] = logo return logo else: @@ -326,23 +329,16 @@ def render_game_card( ) return main_img.convert('RGB') - # Calculate maximum dimensions for each logo based on available space - away_max_width, away_max_height = self._calculate_max_logo_dimensions(score_width, 'away') - home_max_width, home_max_height = self._calculate_max_logo_dimensions(score_width, 'home') - - # Resize logos to fit within their allocated space - away_logo = self._resize_logo_to_fit(away_logo, away_max_width, away_max_height) - home_logo = self._resize_logo_to_fit(home_logo, home_max_width, home_max_height) - center_y = self.display_height // 2 - - # Calculate logo positions - ensure they stay on screen - # Away logo: positioned from left edge with padding - away_x = 10 + + # Place logos — each centered within a slot on its side; cap at half the card + # width so home_slot_start stays non-negative on square/tall displays + logo_slot = min(self.display_height, self.display_width // 2) + away_x = (logo_slot - away_logo.width) // 2 away_y = center_y - (away_logo.height // 2) - - # Home logo: positioned from right edge with padding - home_x = self.display_width - home_logo.width - 10 + + home_slot_start = self.display_width - logo_slot + home_x = home_slot_start + (logo_slot - home_logo.width) // 2 home_y = center_y - (home_logo.height // 2) # Draw logos diff --git a/plugins/soccer-scoreboard/manager.py b/plugins/soccer-scoreboard/manager.py index 4958c3e..1dd8704 100644 --- a/plugins/soccer-scoreboard/manager.py +++ b/plugins/soccer-scoreboard/manager.py @@ -345,7 +345,7 @@ def _adapt_config_for_manager(self, league_key: str) -> Dict[str, Any]: leagues_config = self.config.get('leagues', {}) league_config = leagues_config.get(league_key, {}) - self.logger.debug(f"DEBUG: league_config for {league_key} = {league_config}") + self.logger.debug(f"league_config for {league_key} = {league_config}") # Extract nested configurations display_modes_config = league_config.get("display_modes", {}) @@ -1684,7 +1684,7 @@ def get_info(self) -> Dict[str, Any]: info = { "plugin_id": self.plugin_id, "name": "Soccer Scoreboard", - "version": "1.3.1", + "version": "1.4.0", "enabled": self.is_enabled, "display_size": f"{self.display_width}x{self.display_height}", "leagues": league_info, diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index e52c631..75623c6 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "1.3.1", + "version": "1.4.4", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, and more", "category": "sports", @@ -24,6 +24,31 @@ "soccer_upcoming" ], "versions": [ + { + "released": "2026-02-25", + "version": "1.4.4", + "ledmatrix_min": "2.0.0" + }, + { + "released": "2026-02-24", + "version": "1.4.3", + "ledmatrix_min": "2.0.0" + }, + { + "version": "1.4.2", + "ledmatrix_min": "2.0.0", + "released": "2026-02-24" + }, + { + "version": "1.4.1", + "ledmatrix_min": "2.0.0", + "released": "2026-02-24" + }, + { + "version": "1.4.0", + "ledmatrix_min": "2.0.0", + "released": "2026-02-24" + }, { "version": "1.3.1", "ledmatrix_min": "2.0.0", @@ -40,7 +65,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-02-15", + "last_updated": "2026-02-24", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/soccer-scoreboard/scroll_display.py b/plugins/soccer-scoreboard/scroll_display.py index 729e391..1f9c3de 100644 --- a/plugins/soccer-scoreboard/scroll_display.py +++ b/plugins/soccer-scoreboard/scroll_display.py @@ -143,6 +143,7 @@ def _get_scroll_settings(self) -> Dict[str, Any]: 'show_league_separators': scroll_config.get('show_league_separators', True), 'min_duration': scroll_config.get('min_duration', 30), 'max_duration': scroll_config.get('max_duration', 300), + 'game_card_width': scroll_config.get('game_card_width', 128), } def _configure_scroll_helper(self) -> None: @@ -323,10 +324,12 @@ def prepare_scroll_content( scroll_settings = self._get_scroll_settings() gap_between_games = scroll_settings.get("gap_between_games", 24) show_separators = scroll_settings.get("show_league_separators", True) + game_card_width = scroll_settings.get("game_card_width", 128) - # Create game renderer + # Create game renderer using game_card_width so cards are a fixed size + # regardless of the full chain width (display_width may span multiple panels) renderer = GameRenderer( - self.display_width, + game_card_width, self.display_height, self.config, self.plugin_dir diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index ab39f07..f481af1 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -998,10 +998,15 @@ def _fetch_todays_games(self) -> Optional[Dict]: self.logger.debug(f"Using cached current scoreboard for {self.sport}/{self.league}") return cached_data - # For soccer, use today's date - now = datetime.now() + # ESPN API anchors its schedule calendar to Eastern US time. + # Always query using the Eastern date + 1-day lookback to catch + # late-night games still in progress from the previous Eastern day. + tz = pytz.timezone("America/New_York") + now = datetime.now(tz) + yesterday = now - timedelta(days=1) formatted_date = now.strftime("%Y%m%d") - params = {"dates": formatted_date, "limit": 1000} + formatted_date_yesterday = yesterday.strftime("%Y%m%d") + params = {"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000} self.logger.debug(f"Fetching today's games for {self.sport}/{self.league} on date {formatted_date}") response = self.session.get( diff --git a/plugins/soccer-scoreboard/widgets/custom-leagues.js b/plugins/soccer-scoreboard/widgets/custom-leagues.js index 949d5d8..d7acac6 100644 --- a/plugins/soccer-scoreboard/widgets/custom-leagues.js +++ b/plugins/soccer-scoreboard/widgets/custom-leagues.js @@ -47,7 +47,6 @@ * Render the custom leagues widget */ render: function(container, config, value, options) { - console.log('[CustomLeaguesWidget] Render called (server-side rendered)'); }, /** @@ -427,5 +426,4 @@ } }; - console.log('[CustomLeaguesWidget] Custom leagues widget registered (soccer-scoreboard plugin)'); })(); diff --git a/plugins/ufc-scoreboard/config_schema.json b/plugins/ufc-scoreboard/config_schema.json index ab458c7..89ca1d5 100644 --- a/plugins/ufc-scoreboard/config_schema.json +++ b/plugins/ufc-scoreboard/config_schema.json @@ -96,6 +96,51 @@ } } }, + "scroll_settings": { + "type": "object", + "title": "Scroll Settings", + "description": "Settings for scroll display mode (when display mode is set to 'scroll')", + "properties": { + "scroll_speed": { + "type": "number", + "default": 50.0, + "minimum": 1.0, + "maximum": 200.0, + "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." + }, + "scroll_delay": { + "type": "number", + "default": 0.01, + "minimum": 0.001, + "maximum": 0.1, + "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." + }, + "gap_between_games": { + "type": "integer", + "default": 48, + "minimum": 8, + "maximum": 128, + "description": "Gap in pixels between fight cards when scrolling" + }, + "show_league_separators": { + "type": "boolean", + "default": true, + "description": "Show league icons between different leagues" + }, + "dynamic_duration": { + "type": "boolean", + "default": true, + "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each fight card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more fights simultaneously." + } + } + }, "live_priority": { "type": "boolean", "default": true, diff --git a/plugins/ufc-scoreboard/fight_renderer.py b/plugins/ufc-scoreboard/fight_renderer.py index 711bd68..73794af 100644 --- a/plugins/ufc-scoreboard/fight_renderer.py +++ b/plugins/ufc-scoreboard/fight_renderer.py @@ -164,10 +164,12 @@ def _load_headshot( if img.mode != "RGBA": img = img.convert("RGBA") - # Scale headshot to fit display height - max_width = int(self.display_width * 1.5) - max_height = int(self.display_height * 1.5) - img.thumbnail((max_width, max_height), LANCZOS) + # Crop transparent padding then scale so ink fills display_height. + # thumbnail into a display_height square box preserves aspect ratio. + bbox = img.getbbox() + if bbox: + img = img.crop(bbox) + img.thumbnail((self.display_height, self.display_height), LANCZOS) img.load() # Ensure pixel data is loaded before closing file self._headshot_cache[fighter_id] = img return img diff --git a/plugins/ufc-scoreboard/manifest.json b/plugins/ufc-scoreboard/manifest.json index 2869d43..edfa4cd 100644 --- a/plugins/ufc-scoreboard/manifest.json +++ b/plugins/ufc-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ufc-scoreboard", "name": "UFC Scoreboard", - "version": "1.1.1", + "version": "1.2.3", "author": "LegoGuy1000", "contributors": [ { @@ -14,7 +14,14 @@ "entry_point": "manager.py", "description": "Live, recent, and upcoming UFC fights with fighter headshots, records, odds, and fight results. Based on original work by Alex Resnick (legoguy1000).", "category": "sports", - "tags": ["ufc", "mma", "fighting", "sports", "scoreboard", "live-scores"], + "tags": [ + "ufc", + "mma", + "fighting", + "sports", + "scoreboard", + "live-scores" + ], "icon": "fas fa-fist-raised", "display_modes": [ "ufc_live", @@ -25,6 +32,26 @@ "default_duration": 15, "config_schema": "config_schema.json", "versions": [ + { + "released": "2026-02-25", + "version": "1.2.3", + "ledmatrix_min": "2.0.0" + }, + { + "version": "1.2.2", + "ledmatrix_min": "2.0.0", + "released": "2026-02-24" + }, + { + "version": "1.2.1", + "ledmatrix_min": "2.0.0", + "released": "2026-02-24" + }, + { + "version": "1.2.0", + "ledmatrix_min": "2.0.0", + "released": "2026-02-24" + }, { "version": "1.1.1", "ledmatrix_min": "2.0.0", @@ -47,7 +74,7 @@ "released": "2026-02-12" } ], - "last_updated": "2026-02-15", + "last_updated": "2026-02-24", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/ufc-scoreboard/scroll_display.py b/plugins/ufc-scoreboard/scroll_display.py index 79aa94c..ae980ec 100644 --- a/plugins/ufc-scoreboard/scroll_display.py +++ b/plugins/ufc-scoreboard/scroll_display.py @@ -143,6 +143,7 @@ def _get_scroll_settings(self) -> Dict[str, Any]: "gap_between_games": 48, "show_league_separators": True, "dynamic_duration": True, + "game_card_width": 128, } ufc_config = self.config.get("ufc", {}) @@ -235,15 +236,16 @@ def prepare_and_display( scroll_settings = self._get_scroll_settings() gap_between_fights = scroll_settings.get("gap_between_games", 48) show_separators = scroll_settings.get("show_league_separators", True) + game_card_width = scroll_settings.get("game_card_width", 128) # Get display options from UFC config ufc_config = self.config.get("ufc", {}) display_options = ufc_config.get("display_options", {}) - # Reuse cached fight renderer - if self._renderer is None: + # Reuse cached fight renderer; recreate if game_card_width changed + if self._renderer is None or getattr(self._renderer, "display_width", None) != game_card_width: self._renderer = FightRenderer( - self.display_width, + game_card_width, self.display_height, self.config, headshot_cache=self._headshot_cache, diff --git a/plugins/ufc-scoreboard/sports.py b/plugins/ufc-scoreboard/sports.py index dee1a8d..7fc17bf 100644 --- a/plugins/ufc-scoreboard/sports.py +++ b/plugins/ufc-scoreboard/sports.py @@ -860,14 +860,20 @@ def _fetch_data(self) -> Optional[Dict]: def _fetch_todays_games(self) -> Optional[Dict]: """Fetch only today's games for live updates (not entire season).""" try: - now = datetime.now() + # ESPN API anchors its schedule calendar to Eastern US time. + # Always query using the Eastern date + 1-day lookback to catch + # late-night games still in progress from the previous Eastern day. + tz = pytz.timezone("America/New_York") + now = datetime.now(tz) + yesterday = now - timedelta(days=1) formatted_date = now.strftime("%Y%m%d") + formatted_date_yesterday = yesterday.strftime("%Y%m%d") # Fetch todays games only url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" self.logger.debug(f"Fetching today's games for {self.sport}/{self.league} on date {formatted_date}") response = self.session.get( url, - params={"dates": formatted_date, "limit": 1000}, + params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000}, headers=self.headers, timeout=10, )