diff --git a/plugins.json b/plugins.json index c3b0dc35..1917cc88 100644 --- a/plugins.json +++ b/plugins.json @@ -80,9 +80,9 @@ "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.55.0", + "latest_version": "1.56.0", "ledmatrix_min_version": "3.8.0", - "commit": "b99d6eccbcf083ac6f6c46eebdf22964a8b9eece" + "commit": "43c19f76c144c1b8cecd3d811706a1de371c7ea7" }, { "id": "basketball-scoreboard", diff --git a/plugins/baseball-scoreboard/CHANGELOG.md b/plugins/baseball-scoreboard/CHANGELOG.md index 4923a904..ce230d98 100644 --- a/plugins/baseball-scoreboard/CHANGELOG.md +++ b/plugins/baseball-scoreboard/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [1.56.0] - 2026-10-02 + +### Added +- Game activity pop-ups: a one-line banner along the bottom of the live scorebug + for the plays between runs -- `Harper SINGLE! Top 8th`, + `Acuna Jr. STEALS 3RD Bot 1st`, `McFarlane IN RELIEF Bot 7th` -- holding for + a few seconds and fading back to the scorebug. Runs are left to the + celebration and the scorer card. +- Per-league `display_options.show_game_activity` (off by default; MLB and NCAA + Baseball, which have play-by-play), `game_activity_detail` + (`hits_and_steals`, `highlights`, `everything`), `game_activity_dwell_seconds` + and `game_activity_fade_seconds`. +- One extra ESPN request per live update for the game on screen, shared with the + celebration's when a run has just scored. A first look at a game only records + where the feed is, so joining mid-inning replays nothing. + ## [1.55.0] - 2026-10-02 ### Added diff --git a/plugins/baseball-scoreboard/README.md b/plugins/baseball-scoreboard/README.md index bd620e0c..43898881 100644 --- a/plugins/baseball-scoreboard/README.md +++ b/plugins/baseball-scoreboard/README.md @@ -31,6 +31,7 @@ inning, bases, outs and count are what the panel showed at that moment.* - [Per-league: update intervals](#per-league-update-intervals) - [Per-league: what appears on the card](#per-league-what-appears-on-the-card) - [Run and home-run celebrations](#run-and-home-run-celebrations) + - [Game activity pop-ups](#game-activity-pop-ups) - [The extra baseball screens](#the-extra-baseball-screens) - [The matchup card: separator, date and time](#the-matchup-card-separator-date-and-time) - [Favourite team result colours](#favourite-team-result-colours) @@ -505,6 +506,37 @@ play-by-play, so it exists for MLB and NCAA Baseball only. | `scorer_card_dwell_seconds` | `6` | **Advanced.** Seconds on screen (3-20) | | `scorer_card_favorites_only` | `false` | **Advanced.** Only for a favourite team's runs | +### Game activity pop-ups + +Between runs a scoreless inning can look frozen. With pop-ups on, a one-line +banner appears along the bottom of the live scorebug for the plays that happen +-- `Harper SINGLE! Top 8th`, `Acuna Jr. STEALS 3RD Bot 1st`, +`McFarlane IN RELIEF Bot 7th` -- holds for a few seconds, then fades back to +the scorebug. It is the same idea as the hockey scoreboard's game-activity +banner. Runs are left to the celebration and the scorer card. + +The banner picks the fullest wording that fits: a smaller font is tried before +any word is given up, then the surname alone, the short label (`1B`, `SB`, +`K`), the short inning (`T8`), and last of all the inning itself. Accents are +folded (`Acuna`), because the small face has no accented letters. + +It is **off by default**, only draws on panels 32 pixels tall or more, and +reads ESPN's play-by-play, so it exists for MLB and NCAA Baseball only. It +costs one extra ESPN request per live update for the game on screen, shared +with the celebration when a run has just scored. + +| Setting | Default | What it does | +|---|---|---| +| `display_options.show_game_activity` | `false` | **Advanced.** Show the pop-ups (MLB and NCAA Baseball) | +| `game_activity_detail` | `highlights` | **Advanced.** `hits_and_steals`: singles, doubles, triples, stolen bases. `highlights`: those plus strikeouts, walks, double plays, errors and pitching changes. `everything`: also outs and sacrifices | +| `game_activity_dwell_seconds` | `6` | **Advanced.** How long each pop-up stays, fade included (2-20) | +| `game_activity_fade_seconds` | `3` | **Advanced.** How much of that is spent fading out; `0` removes it at once (0-10) | + +A pop-up can trail the play by up to one live update interval; the inning on it +says when it happened. A burst of plays keeps the newest three. The first look +at a game only records where the feed is, so joining a game mid-inning does not +replay its history. + ### The extra baseball screens Baseball has more to say than most sports mid-at-bat, so the plugin can @@ -973,6 +1005,7 @@ baseball-scoreboard/ ├── manager.py # BaseballScoreboardPlugin: league and mode routing ├── baseball_celebration.py # Run / home-run / win celebrations ├── baseball_scorer_card.py # The card naming who drove a run in +├── baseball_activity.py # Play-by-play pop-up banners ├── mlb_managers.py # MLB fetching and cache keys ├── milb_managers.py # MiLB fetching ├── ncaa_baseball_managers.py # NCAA fetching diff --git a/plugins/baseball-scoreboard/baseball.py b/plugins/baseball-scoreboard/baseball.py index 1b08a1d9..e6cc62d9 100644 --- a/plugins/baseball-scoreboard/baseball.py +++ b/plugins/baseball-scoreboard/baseball.py @@ -12,6 +12,7 @@ from PIL import Image, ImageDraw, ImageFont +from baseball_activity import BaseballActivityMixin from baseball_celebration import BaseballCelebrationMixin from baseball_scorer_card import BaseballScorerCardMixin from data_sources import ESPNDataSource @@ -1143,7 +1144,8 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: self.fonts["time"] = original -class BaseballLive(BaseballScorerCardMixin, BaseballCelebrationMixin, Baseball, SportsLive): +class BaseballLive(BaseballActivityMixin, BaseballScorerCardMixin, BaseballCelebrationMixin, + Baseball, SportsLive): """Base class for live baseball games.""" def __init__( @@ -1181,6 +1183,7 @@ def __init__( self._headshot_mgr = None # lazily created in the render path self._init_celebration() self._init_scorer_card() + self._init_game_activity() def _at_bat_card_style(self) -> str: """'card' (the default) draws the pitcher/batter screen as a full @@ -1205,6 +1208,8 @@ def update(self): self._check_scorer_cards() if self.test_mode: return + if self.show_game_activity and self.espn_summary_sport_league: + self._poll_game_activity() pbp_wanted = self.show_pitcher_batter or self.show_last_play or self.show_player_card if not pbp_wanted: return @@ -2790,6 +2795,8 @@ def text_width(text, font): # Composite the text overlay onto the main image main_img = Image.alpha_composite(main_img, overlay) main_img = main_img.convert("RGB") # Convert for display + # The play-by-play banner along the bottom row, when one is due. + main_img = self._with_activity_popup(main_img, game) # Display the final image self.display_manager.image.paste(main_img, (0, 0)) diff --git a/plugins/baseball-scoreboard/baseball_activity.py b/plugins/baseball-scoreboard/baseball_activity.py new file mode 100644 index 00000000..2771137f --- /dev/null +++ b/plugins/baseball-scoreboard/baseball_activity.py @@ -0,0 +1,398 @@ +"""Game-activity pop-ups for the live baseball scorebug. + +A one-line banner along the bottom of the live scorebug for the plays between +runs -- "Harper SINGLE! Top 8th", "Acuna Jr. STEALS 3RD Bot 2nd" -- so a +scoreless half-inning still shows something happening. Hockey's scoreboard has +the same feature for shots and penalties; this is baseball's version. + +The scoreboard feed carries none of it, so it is the game summary the +celebration and the scorer card already read, polled for the game on screen at +the live update interval, in a thread. The render path only reads a queue. + +ESPN's baseball plays are one row per *pitch* plus one "play-result" row for +how the at-bat ended ("Harper singled to left, Turner scored."), and the text of +that row is the one sentence worth showing. Runs are left to the celebration +and the scorer card, as hockey leaves goals: a play flagged ``scoringPlay`` is +never a pop-up. +""" + +import re +import threading +import time +import unicodedata +from collections import deque +from typing import Any, Deque, Dict, List, Optional, Tuple + +from PIL import Image, ImageDraw + +#: Never poll faster than this, whatever live_update_interval says. +MIN_POLL_SECONDS = 10.0 +#: A summary takes longer than the 2 s the update thread will wait for one; this +#: thread has no such limit. +SUMMARY_TIMEOUT_SECONDS = 15.0 +#: The banner needs a row of its own, so it is only drawn this tall or taller. +MIN_PANEL_HEIGHT = 32 +#: A burst of plays keeps the newest few rather than replaying them all. +MAX_QUEUED = 3 + +#: What each detail level pops up for. "highlights" is the default. +DETAIL_KINDS = { + "hits_and_steals": frozenset({"hit", "steal"}), + "highlights": frozenset({"hit", "steal", "strikeout", "walk", + "pitching_change", "double_play", "error"}), + "everything": frozenset({"hit", "steal", "strikeout", "walk", + "pitching_change", "double_play", "error", + "out", "sacrifice"}), +} +DEFAULT_DETAIL = "highlights" + +_ORDINALS = {1: "st", 2: "nd", 3: "rd"} + +# (kind, regex on ESPN's result sentence, long label, short label), first match +# wins, so the more specific sentences come first. The regex's "name" group is +# the player the pop-up is about. +_NAME = r"^(?P[^,]+?)" +_RULES: Tuple[Tuple[str, "re.Pattern", str, str], ...] = tuple( + (kind, re.compile(pattern, re.I), long_label, short_label) + for kind, pattern, long_label, short_label in ( + ("double_play", _NAME + r"\s+(?:grounded|hit|lined|flied|popped)\b.*\b(?:double|triple) play", + "DOUBLE PLAY", "DP"), + ("pitching_change", _NAME + r"\s+relieved\b", "IN RELIEF", "RELIEF"), + ("walk", _NAME + r"\s+intentionally walked", "INTENTIONAL WALK", "IBB"), + ("walk", _NAME + r"\s+walked", "WALKS", "BB"), + ("walk", _NAME + r"\s+(?:was )?hit by (?:a )?pitch", "HIT BY PITCH", "HBP"), + ("strikeout", _NAME + r"\s+struck out", "STRIKES OUT", "K"), + ("steal", _NAME + r"\s+stole\s+(?Psecond|third|home)", + "STEALS {base}", "SB"), + ("steal", _NAME + r"\s+caught stealing", "CAUGHT STEALING", "CS"), + ("hit", _NAME + r"\s+tripled\b", "TRIPLE!", "3B"), + ("hit", _NAME + r"\s+doubled\b", "DOUBLE!", "2B"), + ("hit", _NAME + r"\s+singled\b", "SINGLE!", "1B"), + ("hit", _NAME + r"\s+reached on (?:an? )?infield single\b", "INFIELD SINGLE", "1B"), + ("error", _NAME + r"\s+reached on (?:a )?(?:fielding |throwing )?error", + "REACHES ON ERROR", "E"), + ("sacrifice", _NAME + r"\s+hit (?:a )?sacrifice", "SACRIFICE", "SAC"), + ("out", _NAME + r"\s+(?:grounded|bunted) into (?:a )?fielder's choice", + "FIELDER'S CHOICE", "FC"), + ("out", _NAME + r"\s+(?:grounded|flied|lined|popped|fouled)\s+out", "OUT", "OUT"), + ) +) + +#: ESPN rows that are pitches, markers or bookkeeping rather than a result. +_NOT_RESULT_TYPES = frozenset({ + "ball", "foul-ball", "start-batterpitcher", "end-batterpitcher", + "start-inning", "end-inning", "start-half-inning", "end-half-inning", + "bunted-foul", +}) + + +def _ascii(text: str) -> str: + """Accents folded away: the 4x6 face the banner falls back to has no + "n with tilde", and a missing glyph draws as a box in the middle of a name.""" + return unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii") + + +def _ordinal(n: int) -> str: + if 10 <= n % 100 <= 20: + return f"{n}th" + return f"{n}{_ORDINALS.get(n % 10, 'th')}" if n % 10 in _ORDINALS else f"{n}th" + + +def inning_stamps(play: Dict) -> List[str]: + """"Top 8th", then "T8" -- the long wording first.""" + period = play.get("period") or {} + half = str(period.get("type") or "").strip() + try: + number = int(period.get("number")) + except (TypeError, ValueError): + return [] + if half.lower().startswith("top"): + return [f"Top {_ordinal(number)}", f"T{number}"] + if half.lower().startswith(("bot", "bottom")): + return [f"Bot {_ordinal(number)}", f"B{number}"] + return [f"{_ordinal(number)} inn", str(number)] + + +def extract_activity(play: Dict) -> Optional[Dict]: + """One ESPN play as a pop-up, or None. + + None for pitches, inning markers, anything unrecognised and every scoring + play (the celebration and the scorer card own runs). Otherwise a dict with + ``kind``, the player's ``names`` (fullest first), the ``labels`` (long, + short), the ``stamps`` (long, short), the acting ``team_id`` and the play's + ``text``. + """ + if not isinstance(play, dict) or play.get("scoringPlay"): + return None + text = str(play.get("text") or "").strip() + ptype = str((play.get("type") or {}).get("type") or "") + # A pitch row's text is "Pitch 2 : Ball In Play"; its result is the next row. + if not text or text.startswith("Pitch ") or ptype in _NOT_RESULT_TYPES: + return None + for kind, pattern, long_label, short_label in _RULES: + match = pattern.search(text) + if match is None: + continue + # "Baldwin struck out looking, Acuna Jr. stole second." is a strikeout + # about Baldwin: the name is whoever the sentence starts with, and stops + # at the first comma. Not stripped of a full stop -- "Acuna Jr." has one. + name = _ascii(match.group("name").strip(" ,")) + if not name or len(name) > 24: + return None + groups = match.groupdict() + long_label = long_label.format(base=(groups.get("base") or "").upper().replace( + "SECOND", "2ND").replace("THIRD", "3RD").replace("HOME", "HOME")) + parts = name.split() + # The surname alone is the fallback for a tight panel, unless it is + # only a suffix ("Acuna Jr." must not shrink to "Jr."). + suffixes = {"jr.", "sr.", "ii", "iii", "iv", "v"} + names = [name] + if len(parts) > 1 and parts[-1].lower() not in suffixes: + names.append(parts[-1]) + return { + "kind": kind, + "names": list(dict.fromkeys(names)), + "labels": [long_label, short_label], + "stamps": inning_stamps(play), + "team_id": str((play.get("team") or {}).get("id") or ""), + "text": text, + "play_id": str(play.get("id") or ""), + } + return None + + +def wanted_kinds(detail: Any) -> frozenset: + """The kinds a detail level pops up for; unknown values mean the default.""" + return DETAIL_KINDS.get(str(detail or DEFAULT_DETAIL), DETAIL_KINDS[DEFAULT_DETAIL]) + + +class BaseballActivityMixin: + """Polls, queues and draws the pop-up banner for a live baseball manager. + + The host provides what ``BaseballLive`` does: ``mode_config``, + ``current_game``, ``live_games``, ``update_interval``, ``fonts``, + ``logger``, ``espn_summary_sport_league`` and ``_fetch_summary``. + """ + + def _init_game_activity(self) -> None: + cfg = self.mode_config + self.show_game_activity = bool(cfg.get("show_game_activity", False)) + self.game_activity_kinds = wanted_kinds(cfg.get("game_activity_detail")) + self.game_activity_dwell = max(1.0, float(cfg.get("game_activity_dwell_seconds", 6) or 6)) + self.game_activity_fade = min( + max(0.0, float(cfg.get("game_activity_fade_seconds", 3) or 0)), + self.game_activity_dwell) + self._activity_queue: Deque[Dict] = deque(maxlen=MAX_QUEUED) + self._activity_popup: Optional[Dict] = None + self._activity_seen: Dict[str, Optional[str]] = {} + self._activity_inflight = False + self._activity_last_poll = 0.0 + self._activity_drawn_at = 0.0 + + # ------------------------------------------------------------------ + # Polling + # ------------------------------------------------------------------ + + def _poll_game_activity(self) -> None: + """Start a summary fetch for the game on screen, at most once per live + update interval and never two at once. + + Only while the scorebug is actually being drawn where a pop-up can + show: update() runs whether or not baseball is on screen, and in scroll + mode or on a panel too short for the banner it would be spending a + request per interval on pop-ups nobody sees. Pausing also forgets where + each feed was up to, so coming back starts from a fresh baseline rather + than replaying the gap as a run of stale pop-ups.""" + game = getattr(self, "current_game", None) + if not game or not game.get("id") or self._activity_inflight: + return + now = time.time() + interval = max(MIN_POLL_SECONDS, float(getattr(self, "update_interval", 0) or 0)) + if now - self._activity_drawn_at > 2 * interval: + self._activity_seen.clear() + self._activity_queue.clear() + return + if now - self._activity_last_poll < interval: + return + self._activity_last_poll = now + self._activity_inflight = True + live_ids = {str(g.get("id")) for g in (getattr(self, "live_games", None) or [])} + for game_id in [k for k in self._activity_seen if k not in live_ids]: + self._activity_seen.pop(game_id, None) + try: + threading.Thread(target=self._fetch_game_activity, args=(dict(game),), + daemon=True).start() + except RuntimeError as e: # no thread, no fetch: never a stuck flag + self._activity_inflight = False + self.logger.debug(f"Game activity poll not started: {e}") + + def _fetch_game_activity(self, game: Dict) -> None: + try: + summary = self._fetch_summary(str(game["id"]), timeout=SUMMARY_TIMEOUT_SECONDS) + if summary: + self._queue_game_activity(game, summary.get("plays") or []) + except Exception as e: # noqa: BLE001 + self.logger.debug(f"Game activity poll failed for {game.get('id')}: {e}") + finally: + self._activity_inflight = False + + def _queue_game_activity(self, game: Dict, plays: List[Dict]) -> None: + """Queue a pop-up for each wanted play since this game's last poll. + + The first poll of a game only records where the feed is up to: a game + joined mid-inning would otherwise replay every hit it has had. So does a + poll that cannot find the last play it saw, since ESPN has rewritten the + feed and there is no telling which plays are new.""" + game_id = str(game.get("id")) + ids = [str(p.get("id", "") or "") for p in plays] + last = self._activity_seen.get(game_id) + self._activity_seen[game_id] = ids[-1] if ids and ids[-1] else None + if not last or last not in ids: + return + sides = {str(game.get("home_id")): "home", str(game.get("away_id")): "away"} + seen_text = set() + for play in plays[ids.index(last) + 1:]: + activity = extract_activity(play) + if activity is None or activity["kind"] not in self.game_activity_kinds: + continue + # ESPN can carry the same sentence on two rows (a steal is one). + key = (activity["text"], tuple(activity["stamps"])) + if key in seen_text: + continue + seen_text.add(key) + side = sides.get(activity["team_id"]) + activity["game_id"] = game_id + activity["team_abbr"] = game.get(f"{side}_abbr", "") if side else "" + activity["team_color"] = game.get(f"{side}_team_color") if side else None + self._activity_queue.append(activity) + + # ------------------------------------------------------------------ + # Showing it + # ------------------------------------------------------------------ + + def _current_activity_popup(self, game: Dict, dwell: float) -> Optional[Dict]: + """The pop-up to draw over this game right now, if any. Pop-ups queued + for a game the rotation has left are dropped rather than shown late over + a different game.""" + game_id = str(game.get("id") or "") + now = time.time() + popup = self._activity_popup + if popup and popup["game_id"] == game_id and now - popup["shown_at"] < dwell: + return popup + self._activity_popup = None + while self._activity_queue: + candidate = self._activity_queue.popleft() + if candidate["game_id"] == game_id: + self._activity_popup = dict(candidate, shown_at=now) + return self._activity_popup + return None + + @staticmethod + def _cut(draw, text: str, font, width: int) -> str: + """``text`` cut to ``width`` pixels, ending in ".." if it was cut.""" + if draw.textlength(text, font=font) <= width: + return text + while text and draw.textlength(text + "..", font=font) > width: + text = text[:-1] + return (text.rstrip() + "..") if text else "" + + def _layout_activity_banner(self, draw, popup: Dict, width: int, + max_row_h: int) -> Optional[Dict]: + """Font and wording for the banner: the fullest wording that fits the + width, in the largest font whose row fits ``max_row_h``. + + Wording beats size -- a smaller font is tried before any word is given + up. Then, in order: the player's surname alone, the short label, the + long inning, and last of all the inning itself, which is what says when + the play happened. A line too wide even then is cut rather than drawn + past the edge.""" + fonts = [] + for font in (self.fonts.get("time"), self.fonts.get("status"), self.fonts.get("record")): + if font is None: + continue + box = font.getbbox("Ay") + row_h = box[3] - box[1] + 2 + if row_h <= max_row_h: + fonts.append((font, row_h)) + if not fonts: + return None + names = popup.get("names") or [popup.get("team_abbr") or ""] + stamps = list(dict.fromkeys(popup.get("stamps") or [])) + [""] + avail = width - 2 + for stamp in stamps: + for label in popup["labels"]: + for name in names: + for font, row_h in fonts: + text = f"{name} {label}".strip() + gap = draw.textlength(" ", font=font) if stamp else 0 + total = (draw.textlength(text, font=font) + gap + + draw.textlength(stamp, font=font)) + if total <= avail: + return {"font": font, "row_h": row_h, "name": name, + "label": label, "stamp": stamp} + font, row_h = fonts[-1] + return {"font": font, "row_h": row_h, + "name": self._cut(draw, names[-1], font, avail), "label": "", "stamp": ""} + + def _with_activity_popup(self, img: Image.Image, game: Dict) -> Image.Image: + """The scorebug with the current pop-up banner over its bottom row. + + Each frame is a finished screen: the banner holds at full strength, + then its text dims to black over the fade, and the scorebug's own bottom + row returns once it has gone. The text fades rather than the banner + cross-fading into the scorebug, because a cross-fade shows the scorebug + line through the pop-up -- two lines of text on top of each other. A + ramp, so at the 1 FPS a switch-mode board is drawn at it steps down + evenly instead of flickering.""" + if not getattr(self, "show_game_activity", False): + return img + width, height = img.size + if height < MIN_PANEL_HEIGHT: + return img + self._activity_drawn_at = time.time() + try: + dwell, fade = self.game_activity_dwell, self.game_activity_fade + popup = self._current_activity_popup(game, dwell) + if popup is None: + return img + left = dwell - (time.time() - popup["shown_at"]) + strength = 1.0 if fade <= 0 or left >= fade else max(0.0, left / fade) + if strength <= 0: + return img + + # Drawn on a copy, so a failure part-way leaves the scorebug whole. + out = img.copy() + draw = ImageDraw.Draw(out) + layout = popup.get("_layout") + if layout is None or layout.get("size") != (width, height): + layout = self._layout_activity_banner(draw, popup, width, max(9, height // 6)) + if layout is None: + return img + layout["size"] = (width, height) + popup["_layout"] = layout + + font, row_h = layout["font"], layout["row_h"] + text_color, time_color = (255, 255, 255), (170, 170, 170) + label_color = tuple(popup["team_color"]) if popup.get("team_color") else (255, 200, 0) + + def faded(color): + return tuple(int(round(c * strength)) for c in color[:3]) + + label = f" {layout['label']}" if layout["label"] else "" + stamp = f" {layout['stamp']}" if layout["stamp"] else "" + segments = [(layout["name"], text_color), (label, label_color), (stamp, time_color)] + + top = height - row_h + draw.rectangle([0, top, width - 1, height - 1], fill=(0, 0, 0)) + draw.fontmode = "1" + total = sum(draw.textlength(t, font=font) for t, _ in segments) + x = max(1, int((width - total) // 2)) + y = top + 1 - font.getbbox("Ay")[1] + for text, color in segments: + if text: + draw.text((x, y), text, font=font, fill=faded(color)) + x += int(round(draw.textlength(text, font=font))) + return out + except Exception as e: # noqa: BLE001 + self.logger.debug(f"Game activity pop-up skipped: {e}") + return img diff --git a/plugins/baseball-scoreboard/baseball_celebration.py b/plugins/baseball-scoreboard/baseball_celebration.py index 795a5d7b..d2c3f115 100644 --- a/plugins/baseball-scoreboard/baseball_celebration.py +++ b/plugins/baseball-scoreboard/baseball_celebration.py @@ -208,9 +208,11 @@ def has_active_celebration(self) -> bool: #: celebration and the scorer card cost one request between them. _SUMMARY_REUSE_SECONDS = 15.0 - def _fetch_summary(self, game_id: str) -> Optional[Dict]: + def _fetch_summary(self, game_id: str, + timeout: float = SUMMARY_TIMEOUT_SECONDS) -> Optional[Dict]: """ESPN's game summary (plays and rosters), or None. Bounded, never - raises, and shared between callers for a few seconds.""" + raises, and shared between callers for a few seconds. The update thread + waits ``timeout`` seconds; a caller already off-thread can wait longer.""" cache = self.__dict__.setdefault("_summary_cache", {}) hit = cache.get(game_id) if hit and time.time() - hit[0] < self._SUMMARY_REUSE_SECONDS: @@ -230,7 +232,7 @@ def fetch(): threading.Thread(target=fetch, daemon=True).start() try: - data = results.get(timeout=SUMMARY_TIMEOUT_SECONDS) + data = results.get(timeout=timeout) except queue.Empty: return None if not isinstance(data, dict): diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index bfe328cb..137fd1a1 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -471,6 +471,29 @@ "default": false, "description": "Only show the scorer card for a favorite team's runs. Needs Display Options > Show Scorer Card." }, + "game_activity_detail": { + "x-advanced": true, + "type": "string", + "enum": ["hits_and_steals", "highlights", "everything"], + "default": "highlights", + "description": "Which plays get a pop-up. Hits and steals: singles, doubles, triples and stolen bases. Highlights: those plus strikeouts, walks, double plays, errors and pitching changes. Everything: also outs and sacrifices. Needs Display Options > Show Game Activity." + }, + "game_activity_dwell_seconds": { + "x-advanced": true, + "type": "integer", + "default": 6, + "minimum": 2, + "maximum": 20, + "description": "How long each pop-up stays on screen (seconds), fade included. Needs Display Options > Show Game Activity." + }, + "game_activity_fade_seconds": { + "x-advanced": true, + "type": "integer", + "default": 3, + "minimum": 0, + "maximum": 10, + "description": "How much of that time the pop-up spends fading out; 0 removes it at once. Needs Display Options > Show Game Activity." + }, "celebration_enabled": { "type": "boolean", "default": true, @@ -778,6 +801,12 @@ "default": false, "description": "After a run, show a full-screen card for the batter who drove it in: headshot, a team-colour banner (HOME RUN, 2-RUN HOMER, GRAND SLAM, RUN SCORES), name, number and position, season line, and ESPN's sentence for the play, which names the runner. It follows the celebration as a second beat, or appears straight away with the celebration off. Costs one extra ESPN request per run, plus one for the batter's bio the first time that player is seen. MLB and NCAA Baseball only -- MiLB has no play data." }, + "show_game_activity": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Pop up a one-line banner along the bottom of the live scorebug for the plays between runs -- \"Harper SINGLE! Top 8th\" -- fading out after a few seconds, so a scoreless inning still shows the action. Runs are left to the celebration and the scorer card. Only on panels 32 pixels tall or more; shorter panels have no row to spare. MLB and NCAA Baseball only -- MiLB has no play data. Costs one extra ESPN request per live update for the game on screen. Choose which plays under Game Activity Detail." + }, "show_traditional_scoreboard": { "x-advanced": true, "type": "boolean", @@ -1736,6 +1765,29 @@ "default": false, "description": "Only show the scorer card for a favorite team's runs. Needs Display Options > Show Scorer Card." }, + "game_activity_detail": { + "x-advanced": true, + "type": "string", + "enum": ["hits_and_steals", "highlights", "everything"], + "default": "highlights", + "description": "Which plays get a pop-up. Hits and steals: singles, doubles, triples and stolen bases. Highlights: those plus strikeouts, walks, double plays, errors and pitching changes. Everything: also outs and sacrifices. Needs Display Options > Show Game Activity." + }, + "game_activity_dwell_seconds": { + "x-advanced": true, + "type": "integer", + "default": 6, + "minimum": 2, + "maximum": 20, + "description": "How long each pop-up stays on screen (seconds), fade included. Needs Display Options > Show Game Activity." + }, + "game_activity_fade_seconds": { + "x-advanced": true, + "type": "integer", + "default": 3, + "minimum": 0, + "maximum": 10, + "description": "How much of that time the pop-up spends fading out; 0 removes it at once. Needs Display Options > Show Game Activity." + }, "celebration_enabled": { "type": "boolean", "default": true, @@ -2043,6 +2095,12 @@ "default": false, "description": "After a run, show a full-screen card for the batter who drove it in: headshot, a team-colour banner (HOME RUN, 2-RUN HOMER, GRAND SLAM, RUN SCORES), name, number and position, season line, and ESPN's sentence for the play, which names the runner. It follows the celebration as a second beat, or appears straight away with the celebration off. Costs one extra ESPN request per run, plus one for the batter's bio the first time that player is seen. MLB and NCAA Baseball only -- MiLB has no play data." }, + "show_game_activity": { + "x-advanced": true, + "type": "boolean", + "default": false, + "description": "Pop up a one-line banner along the bottom of the live scorebug for the plays between runs -- \"Harper SINGLE! Top 8th\" -- fading out after a few seconds, so a scoreless inning still shows the action. Runs are left to the celebration and the scorer card. Only on panels 32 pixels tall or more; shorter panels have no row to spare. MLB and NCAA Baseball only -- MiLB has no play data. Costs one extra ESPN request per live update for the game on screen. Choose which plays under Game Activity Detail." + }, "show_traditional_scoreboard": { "x-advanced": true, "type": "boolean", diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 72745471..30933601 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -846,6 +846,17 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: # Who just scored: a card after the celebration (MLB and NCAA # only -- it reads ESPN's play-by-play). "show_scorer_card": display_options.get("show_scorer_card", False), + # Pop-up banners for the plays between runs (MLB and NCAA only). + "show_game_activity": display_options.get("show_game_activity", False), + "game_activity_detail": league_config.get( + "game_activity_detail", "highlights" + ), + "game_activity_dwell_seconds": league_config.get( + "game_activity_dwell_seconds", 6 + ), + "game_activity_fade_seconds": league_config.get( + "game_activity_fade_seconds", 3 + ), "scorer_card_dwell_seconds": league_config.get( "scorer_card_dwell_seconds", 6 ), diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index c095a064..7f572615 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.55.0", + "version": "1.56.0", "update_interval": 60, "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", @@ -31,6 +31,12 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "version": "1.56.0", + "released": "2026-10-02", + "ledmatrix_min_version": "3.8.0", + "notes": "Game activity pop-ups, as in the hockey scoreboard: a one-line banner along the bottom of the live scorebug for the plays between runs (a single, a stolen base, a strikeout, a walk, a double play, a pitching change), holding for a few seconds and fading back to the scorebug. New per-league settings, off by default and for MLB and NCAA only (MiLB has no play data): display_options.show_game_activity, game_activity_detail (hits_and_steals, highlights or everything), game_activity_dwell_seconds and game_activity_fade_seconds. Runs are left to the celebration and the scorer card. Costs one extra ESPN request per live update for the game on screen." + }, { "version": "1.55.0", "released": "2026-10-02", diff --git a/plugins/baseball-scoreboard/test_baseball_activity.py b/plugins/baseball-scoreboard/test_baseball_activity.py new file mode 100644 index 00000000..f15d836c --- /dev/null +++ b/plugins/baseball-scoreboard/test_baseball_activity.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +"""Game-activity pop-ups for the live baseball scorebug. + +The plays below are shaped like ESPN's real summary for PHI @ ATL on 2026-09-30: +one row per pitch plus a "play-result" row for how each at-bat ended, with runs +flagged ``scoringPlay``. Pins what becomes a pop-up (results, not pitches; never +a run), the wording ladder, the poll/queue rules (a first poll only records +where the feed is; a poll that loses its place re-baselines), and that the +banner fits every panel size and fades. + +Run: /bin/python plugins/baseball-scoreboard/test_baseball_activity.py +""" + +# pylint: disable=protected-access,unused-argument,import-outside-toplevel + +import logging +import os +import sys +import time +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + +REPO = Path(__file__).resolve().parents[2] +CORE = None +for _c in (os.environ.get("LEDMATRIX_CORE", ""), + str(REPO.parent / "LEDMatrix"), + str(Path.home() / "projects" / "LEDMatrix")): + if _c and (Path(_c) / "assets" / "fonts").is_dir(): + CORE = Path(_c) + break +if CORE is None: + print("SKIP: no LEDMatrix core checkout found (set LEDMATRIX_CORE)") + sys.exit(2) +sys.path.insert(0, str(CORE)) +logging.disable(logging.CRITICAL) + +failures = [] + + +def check(label, ok, detail=None): + print((" PASS " if ok else " FAIL ") + label + + ("" if ok or detail is None else " -- %r" % (detail,))) + if not ok: + failures.append(label) + + +_n = [0] + + +def play(text, ptype="play-result", half="Top", inning=1, team="22", scoring=False, **extra): + _n[0] += 1 + return dict({"id": "p%04d" % _n[0], "text": text, "type": {"type": ptype}, + "period": {"type": half, "number": inning, "displayValue": "x"}, + "team": {"id": team}, "scoringPlay": scoring}, **extra) + + +def main(): + os.chdir(str(CORE)) + from PIL import Image + import baseball + import baseball_activity as ba + import baseball_celebration as bc + import sports + + # -- what becomes a pop-up ------------------------------------------- + print("what becomes a pop-up") + act = ba.extract_activity + check("a pitch row is not one", act(play("Pitch 2 : Ball In Play", ptype="double")) is None) + check("a ball is not one", act(play("Pitch 1 : Ball 1", ptype="ball")) is None) + check("an inning marker is not one", + act(play("Top of the 1st inning", ptype="start-inning")) is None) + check("a run never is (the celebration owns it)", + act(play("Harper singled to left, Turner scored.", scoring=True)) is None) + check("a home run never is", + act(play("Schwarber homered to right center (447 feet).", scoring=True)) is None) + a = act(play("Turner doubled to center.", half="Top", inning=1)) + check("a double", a and a["kind"] == "hit" and a["names"][0] == "Turner" + and a["labels"] == ["DOUBLE!", "2B"] and a["stamps"] == ["Top 1st", "T1"], a) + a = act(play("Bohm singled to left, Harper to third.", half="Bottom", inning=3)) + check("a single that moved a runner, bottom half", + a and a["labels"][0] == "SINGLE!" and a["stamps"][0] == "Bot 3rd", a) + a = act(play("Dubon reached on infield single to shortstop, Albies to second.")) + check("an infield single is a hit", a and a["kind"] == "hit" and a["names"][0] == "Dubon", a) + a = act(play("Marsh struck out swinging.")) + check("a strikeout", a and a["kind"] == "strikeout" and a["labels"][1] == "K", a) + a = act(play("Baldwin struck out looking, Acuña Jr. stole second.")) + check("a strikeout that also had a steal is about the batter", + a and a["kind"] == "strikeout" and a["names"][0] == "Baldwin", a) + a = act(play("Acuña Jr. stole third.", ptype="stolen-base", half="Bottom")) + check("a steal keeps the full stop in 'Jr.' and says which base", + a and a["kind"] == "steal" and a["names"] == ["Acuna Jr."] + and a["labels"][0] == "STEALS 3RD", a) + a = act(play("Acuña Jr. walked.")) + check("a walk", a and a["kind"] == "walk" and a["labels"] == ["WALKS", "BB"], a) + a = act(play("Acuña Jr. intentionally walked.")) + check("an intentional walk", a and a["labels"][1] == "IBB", a) + a = act(play("McFarlane relieved Sánchez", half="Bottom", inning=7)) + check("a pitching change names the pitcher coming in", + a and a["kind"] == "pitching_change" and a["names"][0] == "McFarlane", a) + a = act(play("Harris II grounded into double play, first to catcher to first.")) + check("a double play; a suffix is not a surname", + a and a["kind"] == "double_play" and a["names"] == ["Harris II"], a) + a = act(play("Schwarber grounded out to second, Turner to third.")) + check("an out", a and a["kind"] == "out", a) + a = act(play("Stott grounded into fielder's choice to pitcher, Bohm to second.")) + check("a fielder's choice is an out", a and a["kind"] == "out" and a["labels"][1] == "FC", a) + a = act(play("Kim hit sacrifice bunt, Murphy to second.")) + check("a sacrifice", a and a["kind"] == "sacrifice", a) + for text in ("De La Cruz hit for Marsh", "Hill ran for Sosa", "Harper at first base.", + "Hill in right field."): + check("lineup bookkeeping is not one: %r" % text, act(play(text)) is None) + check("two-word and long names: a surname fallback", + act(play("Ke'Bryan Hayes singled to left."))["names"] == ["Ke'Bryan Hayes", "Hayes"]) + check("accents are folded: the small face has no n-tilde", + act(play("Sánchez singled."))["names"][0] == "Sanchez") + check("not a play at all", act({}) is None and act(None) is None) + + # -- stamps --------------------------------------------------------------- + print("\ninnings") + check("ordinals", [ba.inning_stamps(play("x", half="Top", inning=n))[0] + for n in (1, 2, 3, 4, 11, 12, 13, 21)] + == ["Top 1st", "Top 2nd", "Top 3rd", "Top 4th", "Top 11th", "Top 12th", + "Top 13th", "Top 21st"]) + check("11th, 12th and 13th are not 11st", ba.inning_stamps( + play("x", half="Top", inning=11))[0] == "Top 11th") + check("no inning, no stamp", ba.inning_stamps({}) == []) + + # -- detail levels ------------------------------------------------------------ + print("\ndetail levels") + check("default is highlights", ba.wanted_kinds(None) == ba.DETAIL_KINDS["highlights"] + and ba.wanted_kinds("bogus") == ba.DETAIL_KINDS["highlights"]) + check("hits_and_steals is the smallest", ba.wanted_kinds("hits_and_steals") + == {"hit", "steal"}) + check("everything adds outs and sacrifices", + {"out", "sacrifice"} <= ba.wanted_kinds("everything") + and "out" not in ba.wanted_kinds("highlights")) + + # -- the host ------------------------------------------------------------------ + class Frame: + def __init__(self, w, h): + self.image = Image.new("RGB", (w, h)) + self.matrix = None + + def clear(self): + pass + + def update_display(self): + pass + + class Live(ba.BaseballActivityMixin, bc.BaseballCelebrationMixin, baseball.Baseball): + def __init__(self, width=128, height=32, **cfg): + self.config = {} + self.mode_config = dict({"show_game_activity": True}, **cfg) + self.display_width, self.display_height = width, height + self.logger = logging.getLogger("activity-test") + self._font_cache = {} + self.fonts = sports.SportsCore._load_fonts(self) + self.display_manager = Frame(width, height) + self.live_games = [] + self.current_game = None + self.update_interval = 30 + self.espn_summary_sport_league = ("baseball", "mlb") + self.summary = {"plays": []} + self._init_celebration() + self._init_game_activity() + + def _fetch_data(self): # pragma: no cover + raise NotImplementedError + + def _fetch_summary(self, game_id, timeout=None): + return self.summary + + game = {"id": "g1", "away_id": "22", "home_id": "15", "away_abbr": "PHI", + "home_abbr": "ATL", "away_team_color": (200, 30, 40)} + + # -- the queue ------------------------------------------------------------------- + print("\nthe queue") + live = Live() + base = [play("Marsh struck out swinging."), play("Turner doubled to center.")] + live._queue_game_activity(game, base) + check("the first poll only records where the feed is", len(live._activity_queue) == 0 + and live._activity_seen["g1"] == base[-1]["id"]) + new = [play("Harper singled to left."), play("Pitch 1 : Ball 1", ptype="ball"), + play("Bohm walked.")] + live._queue_game_activity(game, base + new) + q = list(live._activity_queue) + check("the next poll queues the new results, not the pitches", + [x["kind"] for x in q] == ["hit", "walk"], [x["kind"] for x in q]) + check("each carries its game and the acting team", + q[0]["game_id"] == "g1" and q[0]["team_abbr"] == "PHI" + and tuple(q[0]["team_color"]) == (200, 30, 40), q[0]) + + live = Live(game_activity_detail="hits_and_steals") + live._queue_game_activity(game, base) + live._queue_game_activity(game, base + [play("Marsh struck out swinging."), + play("Harper singled to left.")]) + check("the detail level filters", [x["kind"] for x in live._activity_queue] == ["hit"]) + + live = Live() + live._queue_game_activity(game, base) + live._queue_game_activity(game, [play("Harper singled to left.")]) + check("a poll that cannot find its place re-baselines instead of replaying", + len(live._activity_queue) == 0) + + live = Live() + live._queue_game_activity(game, base) + burst = [play("%s singled." % n) for n in ("Aa", "Bb", "Cc", "Dd", "Ee")] + live._queue_game_activity(game, base + burst) + check("a burst keeps the newest three", + [x["names"][0] for x in live._activity_queue] == ["Cc", "Dd", "Ee"]) + + live = Live() + live._queue_game_activity(game, base) + same = play("Acuña Jr. stole third.", ptype="stolen-base") + again = dict(play("Acuña Jr. stole third."), period=dict(same["period"])) + live._queue_game_activity(game, base + [same, again]) + check("the same sentence on two rows pops up once", len(live._activity_queue) == 1) + + # -- the popup ----------------------------------------------------------------- + print("\nthe pop-up") + live = Live() + live._activity_queue.extend([ + {"game_id": "other", "kind": "hit", "names": ["X"], "labels": ["SINGLE!", "1B"], + "stamps": [], "team_abbr": "", "team_color": None}, + {"game_id": "g1", "kind": "hit", "names": ["Harper"], "labels": ["SINGLE!", "1B"], + "stamps": ["Top 8th", "T8"], "team_abbr": "PHI", "team_color": None}]) + popup = live._current_activity_popup(game, 6.0) + check("a pop-up for a game the rotation left is dropped, not shown late", + popup and popup["game_id"] == "g1") + check("it holds for its dwell", live._current_activity_popup(game, 6.0) is popup) + popup["shown_at"] -= 7 + check("and is gone after it", live._current_activity_popup(game, 6.0) is None) + + # -- polling ------------------------------------------------------------------- + print("\npolling") + live = Live() + live.current_game = game + live._poll_game_activity() + check("not drawn recently: no poll, state cleared", live._activity_inflight is False + and live._activity_last_poll == 0.0) + live._activity_drawn_at = time.time() + live.summary = {"plays": base} + live._poll_game_activity() + for _ in range(100): + if not live._activity_inflight: + break + time.sleep(0.02) + check("drawn recently: it polls and records the baseline", + live._activity_seen.get("g1") == base[-1]["id"]) + stamp = live._activity_last_poll + live._poll_game_activity() + check("and not again inside the interval", live._activity_last_poll == stamp) + live._activity_inflight = True + live._activity_last_poll = 0 + live._poll_game_activity() + check("never two at once", live._activity_last_poll == 0) + + # -- drawing --------------------------------------------------------------------- + print("\ndrawing") + popup_fields = {"game_id": "g1", "kind": "hit", "names": ["Acuña Jr.", "Jr."], + "labels": ["INTENTIONAL WALK", "IBB"], "stamps": ["Bot 11th", "B11"], + "team_abbr": "ATL", "team_color": (206, 17, 65)} + for width, height in [(64, 32), (96, 32), (128, 32), (192, 32), (256, 32), (128, 64), + (256, 64)]: + live = Live(width, height) + live._activity_queue.append(dict(popup_fields)) + scorebug = Image.new("RGB", (width, height), (10, 10, 30)) + out = live._with_activity_popup(scorebug, game) + label = "%dx%d" % (width, height) + check(label + ": same size", out.size == (width, height)) + top = height - max(9, height // 6) + rows = [y for y in range(height) for x in range(width) + if out.getpixel((x, y)) != scorebug.getpixel((x, y))] + check(label + ": only the bottom rows change", + rows and min(rows) >= top - 1, (min(rows), top) if rows else None) + cols = [x for y in range(height) for x in range(width) + if out.getpixel((x, y)) not in ((0, 0, 0), (10, 10, 30))] + check(label + ": text stays inside the width", cols and min(cols) >= 0 + and max(cols) < width) + + live = Live(128, 24) + live._activity_queue.append(dict(popup_fields)) + scorebug = Image.new("RGB", (128, 24), (10, 10, 30)) + check("a panel under 32px tall is left alone", + live._with_activity_popup(scorebug, game).tobytes() == scorebug.tobytes()) + live = Live(show_game_activity=False) + live._activity_queue.append(dict(popup_fields)) + scorebug = Image.new("RGB", (128, 32), (10, 10, 30)) + check("switched off, nothing is drawn", + live._with_activity_popup(scorebug, game).tobytes() == scorebug.tobytes()) + + live = Live(128, 32) + live._activity_queue.append(dict(popup_fields)) + scorebug = Image.new("RGB", (128, 32), (10, 10, 30)) + full = live._with_activity_popup(scorebug, game) + brightest = lambda im: max(max(im.getpixel((x, y))) for y in range(26, 32) # noqa: E731 + for x in range(128)) + live._activity_popup["shown_at"] -= 5.0 # 1s left of a 6s dwell, 3s fade + dim = live._with_activity_popup(scorebug, game) + check("it fades: a banner near the end of its dwell is dimmer", + brightest(dim) < brightest(full), (brightest(full), brightest(dim))) + live._activity_popup["shown_at"] -= 5.0 + gone = live._with_activity_popup(scorebug, game) + check("and the scorebug's own bottom row returns once it has gone", + gone.tobytes() == scorebug.tobytes()) + + live = Live(128, 32) + live._activity_queue.append(dict(popup_fields)) + first = live._with_activity_popup(scorebug, game) + second = live._with_activity_popup(scorebug, game) + check("steady between frames while it holds", first.tobytes() == second.tobytes()) + + # -- real ESPN rows --------------------------------------------------------------- + print("\nreal feed") + fixture = Path(os.environ.get("TMP", "")) / "sum.json" + if fixture.exists(): + import json + plays = json.load(open(fixture, encoding="utf-8"))["plays"] + kinds = {} + for p in plays: + a = act(p) + if a: + kinds[a["kind"]] = kinds.get(a["kind"], 0) + 1 + check("a real game yields every kind of highlight", {"hit", "strikeout", "walk", "steal", + "pitching_change", "double_play", "out"} <= set(kinds), kinds) + check("none of its 7 runs came through", not any( + act(p) for p in plays if p.get("scoringPlay"))) + else: + print(" (no downloaded ESPN summary at $TMP/sum.json; skipping the live-feed check)") + + if failures: + print("\n%d check(s) failed" % len(failures)) + return 1 + print("\nall checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main())