import logging import os import time from typing import Dict, Any, List, Optional from PIL import Image, ImageDraw, ImageFont logger = logging.getLogger(__name__) class LeaderboardManager: """ Generic leaderboard renderer: scrolls a league strip of: [league logo] [conference label] [#rank] [team logo] [abbr] [record (optional, spacing preserved)] League-specific data is injected via league_data from the league manager. """ def __init__(self, config: Dict[str, Any], display_manager, league_data: Dict[str, Any]): self.leaderboard_config = config.get("leaderboard", {}) self.is_enabled = self.leaderboard_config.get("enabled", False) self.update_interval = self.leaderboard_config.get("update_interval", 3600) self.scroll_speed = max(1, self.leaderboard_config.get("scroll_speed", 1)) self.scroll_delay = self.leaderboard_config.get("scroll_delay", 0.01) self.loop = self.leaderboard_config.get("loop", False) self.show_record = self.leaderboard_config.get("show_record", True) self.display_manager = display_manager self.league_data = league_data self.fonts = self._load_fonts() self._cached_standings: Optional[Dict[str, List[Dict[str, Any]]]] = None self._last_standings_reload: float = 0.0 def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: try: return { 'small': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 6), 'medium': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8), 'large': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10), 'xlarge': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12), } except IOError: logger.warning("Custom fonts not found, falling back to default PIL font.") return {k: ImageFont.load_default() for k in ['small','medium','large','xlarge']} def _get_team_logo(self, logo_path: str) -> Optional[Image.Image]: try: if logo_path and os.path.exists(logo_path): logo = Image.open(logo_path).convert("RGBA") bbox = logo.getbbox() if bbox: logo = logo.crop(bbox) max_h = self.display_manager.height max_w = int(max_h * 1.5) scale = min(max_w / logo.width, max_h / logo.height) return logo.resize((int(logo.width * scale), int(logo.height * scale)), Image.Resampling.LANCZOS) except Exception as e: logger.error(f"Error loading logo {logo_path}: {e}") return None def _fetch_standings(self) -> Dict[str, List[Dict[str, Any]]]: now = time.time() if self._cached_standings and (now - self._last_standings_reload < self.update_interval): return self._cached_standings provider = self.league_data.get("standings_provider") try: standings = provider() if callable(provider) else self.league_data.get("standings", {}) if isinstance(standings, dict): self._cached_standings = standings self._last_standings_reload = now logger.info(f"[Leaderboard] Standings reloaded: {list(standings.keys())}") return standings except Exception as e: logger.error(f"[Leaderboard] standings_provider failed: {e}") return self._cached_standings or {} def display(self): if not self.is_enabled: logger.info("[Leaderboard] Disabled in config") return standings = self._fetch_standings() conferences: List[str] = self.league_data.get("conferences", []) if not conferences or not any(standings.get(conf) for conf in conferences): logger.info("[Leaderboard] No conferences or empty standings") return strip_height = self.display_manager.height logo_spacing = self.leaderboard_config.get("logo_spacing", 20) conference_spacing = self.leaderboard_config.get("conference_spacing", 60) # --- Measure total width --- dummy_img = Image.new("RGB", (1, 1)) draw = ImageDraw.Draw(dummy_img) total_width = 0 league_logo_path = self.league_data.get("league_logo", "") league_logo_probe = self._get_team_logo(league_logo_path) if league_logo_probe: total_width += league_logo_probe.width + logo_spacing for conf in conferences: teams = standings.get(conf, []) if not teams: continue conf_text = f"{conf.capitalize()} Div." total_width += int(draw.textlength(conf_text, font=self.fonts['medium'])) + 40 for rank, team in enumerate(teams, start=1): total_width += int(draw.textlength(f"#{rank}.", font=self.fonts['xlarge'])) + 5 total_width += int(strip_height * 1.5) + 5 # logo box space abbr = team.get("abbreviation", team.get("name", "")[:3]) total_width += int(draw.textlength(abbr, font=self.fonts['large'])) + 10 record = team.get("record", "") total_width += (int(draw.textlength(record, font=self.fonts['medium'])) + 30) if record else 30 total_width += conference_spacing # --- Build strip --- strip = Image.new("RGB", (total_width, strip_height), (0, 0, 0)) draw = ImageDraw.Draw(strip) x_offset = 0 league_logo = self._get_team_logo(league_logo_path) if league_logo: strip.paste(league_logo, (x_offset, (strip_height - league_logo.height) // 2), league_logo) x_offset += league_logo.width + logo_spacing for conf in conferences: teams = standings.get(conf, []) if not teams: continue conf_text = f"{conf.capitalize()} Div." conf_y = (strip_height - (self.fonts['medium'].getbbox(conf_text)[3])) // 2 draw.text((x_offset, conf_y), conf_text, font=self.fonts['medium'], fill=(0, 200, 255)) x_offset += int(draw.textlength(conf_text, font=self.fonts['medium'])) + 40 for rank, team in enumerate(teams, start=1): rank_text = f"#{rank}." rank_y = (strip_height - (self.fonts['xlarge'].getbbox(rank_text)[3])) // 2 draw.text((x_offset, rank_y), rank_text, font=self.fonts['xlarge'], fill=(255, 255, 0)) x_offset += int(draw.textlength(rank_text, font=self.fonts['xlarge'])) + 5 logo = self._get_team_logo(team.get("logo", "")) if logo: strip.paste(logo, (x_offset, (strip_height - logo.height) // 2), logo) x_offset += logo.width + 5 else: # Reserve logo space even if missing, for consistent layout x_offset += int(strip_height * 1.5) + 5 abbr = team.get("abbreviation", team.get("name", "")[:3]) abbr_y = (strip_height - (self.fonts['large'].getbbox(abbr)[3])) // 2 draw.text((x_offset, abbr_y), abbr, font=self.fonts['large'], fill=(255, 255, 255)) x_offset += int(draw.textlength(abbr, font=self.fonts['large'])) + 10 record = team.get("record", "") if self.show_record and record: rec_y = (strip_height - (self.fonts['medium'].getbbox(record)[3])) // 2 draw.text((x_offset, rec_y), record, font=self.fonts['medium'], fill=(255, 255, 0)) x_offset += int(draw.textlength(record, font=self.fonts['medium'])) + 30 else: x_offset += 30 x_offset += conference_spacing # --- Scroll --- visible_w = self.display_manager.width intro_hold = self.leaderboard_config.get("logo_intro_hold", 0) if self.leaderboard_config.get("dynamic_duration", False): est_duration = (total_width / self.scroll_speed) * self.scroll_delay duration = max( self.leaderboard_config.get("min_duration", 30), min(est_duration, self.leaderboard_config.get("max_display_time", 600)), ) else: duration = self.leaderboard_config.get("max_display_time", 600) end_time = time.time() + duration def scroll_once(): for start_x in range(-visible_w, x_offset, self.scroll_speed): # Crop the appropriate segment from the strip frame = strip.crop((max(0, start_x), 0, start_x + visible_w, strip_height)) # Create a visible frame canvas and paste the cropped segment at the right offset frame_canvas = Image.new("RGB", (visible_w, strip_height), (0, 0, 0)) paste_x = 0 if start_x >= 0 else -start_x frame_canvas.paste(frame, (paste_x, 0)) # Paint to the matrix self.display_manager.image.paste(frame_canvas, (0, 0)) self.display_manager.update_display() # Optional intro hold when the strip first fully aligns if start_x == 0 and intro_hold > 0: time.sleep(intro_hold) time.sleep(self.scroll_delay) while True: scroll_once() if not self.loop or time.time() >= end_time: break # --- Static league logo hold at the end --- if league_logo: canvas = Image.new("RGB", (visible_w, strip_height), (0, 0, 0)) offset_x = (visible_w - league_logo.width) // 2 offset_y = (strip_height - league_logo.height) // 2 canvas.paste(league_logo, (offset_x, offset_y), league_logo) self.display_manager.image.paste(canvas, (0, 0)) self.display_manager.update_display() time.sleep(self.leaderboard_config.get("logo_hold", 3))