214 lines
9.9 KiB
Python
214 lines
9.9 KiB
Python
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))
|