diff --git a/plugins.json b/plugins.json index 7ebcaef2..194a1495 100644 --- a/plugins.json +++ b/plugins.json @@ -1424,12 +1424,11 @@ "plugin_path": "plugins/fantasy-blitz", "stars": 0, "downloads": 0, - "last_updated": "2026-09-30", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.1.0", - "ledmatrix_min_version": "3.0.0", - "commit": "90fb3f26f67ccfe17e8c4380480a3c09fd25a7ac" + "latest_version": "1.1.1", + "ledmatrix_min_version": "3.0.0" } ] } diff --git a/plugins/fantasy-blitz/README.md b/plugins/fantasy-blitz/README.md index a39faec6..3ec4c275 100644 --- a/plugins/fantasy-blitz/README.md +++ b/plugins/fantasy-blitz/README.md @@ -166,7 +166,7 @@ are on, and are cut at a whole letter when they are off. | `advanced.animation_fps` | integer (5-60) | `30` | **Advanced.** Frames per second for the animations. | | `advanced.request_timeout` | integer (5-60) | `15` | **Advanced.** Seconds to wait for Sleeper or ESPN before giving up. | | `advanced.headshot_downloads` | boolean | `true` | **Advanced.** Download player photos from ESPN. Off uses only photos already saved. | -| `vegas_mode` | `scroll` / `fixed` / `static` | `"scroll"` | **Advanced.** How Fantasy Blitz behaves in Vegas mode: scroll its top scorers through the ticker, scroll the whole panel as one block, or pause the ticker and show normally. | +| `vegas_mode` | `scroll` / `fixed` / `static` | `"scroll"` | **Advanced.** How Fantasy Blitz behaves in Vegas mode: scroll its top scorers through the ticker, scroll each screen through it as a panel-sized block, or pause the ticker to show one screen per pass (the next screen each time round). | A screen `duration` of 0 means "long enough to show everything": about six seconds per card (`advanced.card_seconds`) and eight per page of a list. diff --git a/plugins/fantasy-blitz/config_schema.json b/plugins/fantasy-blitz/config_schema.json index abcbf5c3..ed976a02 100644 --- a/plugins/fantasy-blitz/config_schema.json +++ b/plugins/fantasy-blitz/config_schema.json @@ -345,7 +345,7 @@ ], "default": "scroll", "x-advanced": true, - "description": "How Fantasy Blitz behaves in Vegas mode: scroll its top scorers through the ticker, scroll the whole panel as one block, or pause the ticker and show normally" + "description": "How Fantasy Blitz behaves in Vegas mode: scroll its top scorers through the ticker, scroll each screen through it as a panel-sized block, or pause the ticker to show one screen per pass (the next screen each time round)" } } } diff --git a/plugins/fantasy-blitz/manager.py b/plugins/fantasy-blitz/manager.py index e59d44ea..f4553d7a 100644 --- a/plugins/fantasy-blitz/manager.py +++ b/plugins/fantasy-blitz/manager.py @@ -74,6 +74,9 @@ #: Seconds each item stays up before a screen moves to the next one. ITEM_SECONDS_DEFAULT = 6 PAGE_SECONDS = 8 +#: ``vegas_mode: fixed`` puts each screen in the ticker as a panel-sized block, +#: this many items of a screen at most. +FIXED_ITEMS_PER_SCREEN = 3 ALERT_SECONDS = 8 #: Screens whose frames keep moving after the intro (foil, sunburst, flame). _INTRO_SECONDS = { @@ -138,6 +141,11 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], display_manager, self._frame_cache: Dict[Tuple[Any, ...], Any] = {} self._shown_key: Optional[Tuple[Any, ...]] = None self._last_warning = 0.0 + # Vegas "static" hands display() one call per pass, with no mode. + self._pause_turn = -1 + self._pause_at = 0.0 + self._pause_seconds = float(self.item_seconds) + self._in_pause = False self.logger.info( "Fantasy Blitz initialised: %sx%s panel, %s scoring, %s modes", @@ -614,6 +622,9 @@ def needs_high_fps(self) -> bool: return bool(self.animations and self._current_screen is not None) def display(self, force_clear: bool = False, display_mode: Optional[str] = None) -> bool: + if display_mode is None and self.enabled and self._vegas_pauses(): + return self._display_pause(time.time()) + self._in_pause = False mode = display_mode or self.modes[self.current_mode_index % len(self.modes)] screen = MODE_SCREENS.get(mode) if screen is None or not self.enabled: @@ -635,11 +646,7 @@ def display(self, force_clear: bool = False, display_mode: Optional[str] = None) return False if screen == "big_play": return self._display_alert(now, force_clear) - if self.phase not in model.SCREEN_PHASES.get(screen, ()): - return False - items = self.content.get(screen) or [] - if screen in LIST_SCREENS: - items = self._pages(items) + items = self._screen_items(screen) if not items: return False @@ -649,6 +656,13 @@ def display(self, force_clear: bool = False, display_mode: Optional[str] = None) t = elapsed - int(elapsed // per) * per return self._show(screen, index, items[index], t, now, force_clear) + def _screen_items(self, screen: str) -> List[Dict[str, Any]]: + """What a screen would draw now: nothing in the wrong part of the week.""" + if self.phase not in model.SCREEN_PHASES.get(screen, ()): + return [] + items = self.content.get(screen) or [] + return self._pages(items) if screen in LIST_SCREENS else items + def _pages(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Split list items into pages that fit the panel.""" _, bw, bh = render.base_size(self.display_manager.width, self.display_manager.height) @@ -744,6 +758,8 @@ def _alert_seconds(self) -> float: def get_display_duration(self) -> float: """Seconds for the mode on screen; ``duration: 0`` means "long enough".""" + if self._in_pause: + return self._pause_seconds screen = self._current_screen if screen is None: return float(self.display_duration) @@ -778,6 +794,10 @@ def get_live_modes(self) -> List[str]: # ------------------------------------------------------------------ def get_vegas_content(self) -> Optional[List[Any]]: + if self.vegas_mode == "fixed": + blocks = self._panel_blocks() + if blocks: + return blocks top = self._vegas_players() if not top: return None @@ -787,6 +807,82 @@ def get_vegas_content(self) -> Optional[List[Any]]: cards.extend(render.vegas_entry(ctx, p, height) for p in top) return cards + def _playlist(self) -> List[Tuple[str, List[Dict[str, Any]]]]: + """Every screen with something to draw right now, in show order.""" + playlist = [] + for screen in SCREEN_TITLES: + if screen == "big_play" or not self._on(screen): + continue + items = self._screen_items(screen) + if items: + playlist.append((screen, items)) + return playlist + + def _still_frame(self, screen: str, item: Dict[str, Any], w: int, h: int) -> Any: + """One finished frame, past every intro animation.""" + title, color = SCREEN_TITLES.get(screen, ("FANTASY", draw.GOLD)) + return render.render_frame(self._renderer_for(screen), self._ctx(), item, w, h, 99.0, title, color) + + def _panel_blocks(self) -> List[Any]: + """``vegas_mode: fixed``: each screen as a panel-sized block in the ticker. + + Every block is a finished screen -- the strip is a still image -- and a + screen contributes at most FIXED_ITEMS_PER_SCREEN of its items. + """ + w, h = self.display_manager.width, self.display_manager.height + blocks: List[Any] = [] + for screen, items in self._playlist(): + for item in items[:FIXED_ITEMS_PER_SCREEN]: + try: + blocks.append(self._still_frame(screen, item, w, h)) + except Exception as exc: # noqa: BLE001 - one bad screen must not blank the ticker + self.logger.error("Could not draw %s for Vegas: %s", screen, exc, exc_info=True) + return blocks + + def _vegas_pauses(self) -> bool: + """True when Vegas stops the scroll for this plugin (``vegas_mode: static``).""" + getter = getattr(self, "get_vegas_participation", None) + if getter is not None: + try: + return getter() == "pause" + except Exception as exc: # noqa: BLE001 - fall back to the setting itself + self.logger.debug("get_vegas_participation() failed: %s", exc) + return self.vegas_mode == "static" + + def _display_pause(self, now: float) -> bool: + """One Vegas pause: a single display() call, then the scroll waits. + + Vegas never calls display() again during the pause, so it can show one + finished frame only. Each pass shows the next screen in the playlist + (and, once the playlist has been round, the next item of each), so + the whole show comes round over successive passes instead of the top + scorers' first card every time. + """ + self._in_pause = True + self._current_mode = None + self._current_screen = None + self._shown_key = None + playlist = self._playlist() + if not playlist: + self._pause_seconds = 1.0 + return False + if now - self._pause_at >= 2.0 or self._pause_turn < 0: + # A second call inside the same pause redraws the same frame. + self._pause_turn += 1 + self._pause_at = now + screen, items = playlist[self._pause_turn % len(playlist)] + item = items[(self._pause_turn // len(playlist)) % len(items)] + w, h = self.display_manager.width, self.display_manager.height + self._pause_seconds = float(PAGE_SECONDS if screen in LIST_SCREENS else self.item_seconds) + try: + frame = self._still_frame(screen, item, w, h) + except Exception as exc: # noqa: BLE001 - never lose the loop to a draw + self.logger.error("Could not draw %s: %s", screen, exc, exc_info=True) + return False + self.display_manager.image.paste(frame, (0, 0)) + self.display_manager.update_display() + return True + def get_vegas_content_type(self) -> str: return "multi" diff --git a/plugins/fantasy-blitz/manifest.json b/plugins/fantasy-blitz/manifest.json index 35a934bf..e5c00fd8 100644 --- a/plugins/fantasy-blitz/manifest.json +++ b/plugins/fantasy-blitz/manifest.json @@ -1,7 +1,7 @@ { "id": "fantasy-blitz", "name": "Fantasy Blitz", - "version": "1.1.0", + "version": "1.1.1", "author": "ChuckBuilds", "description": "An arcade-style NFL fantasy football show with no league login needed: the week's top scorers as collectible player cards with photos, a live leaderboard, big-play celebrations as they happen, dud alerts for the biggest busts, the free agents everyone is adding, injury news, the top scorer at each position, Tuesday awards and the season race. Optionally follows a watchlist and your own Sleeper or ESPN league. Points in PPR, Half-PPR or Standard from Sleeper's public feed, with ESPN for game states and headshots. No API key.", "entry_point": "manager.py", @@ -39,6 +39,12 @@ ">=3.0.0" ], "versions": [ + { + "version": "1.1.1", + "released": "2026-10-02", + "ledmatrix_min_version": "3.0.0", + "notes": "Vegas fixes. static: the scroll pauses for one display() call and the plugin never got another, so it showed the top scorers' first card every time; each pause now shows the next screen (then the next item of each) so the whole show comes round. fixed: now scrolls each screen as a panel-sized block, as the setting always said, instead of the same ticker as scroll." + }, { "version": "1.1.0", "released": "2026-09-30", diff --git a/plugins/fantasy-blitz/test_fantasy_blitz_manager.py b/plugins/fantasy-blitz/test_fantasy_blitz_manager.py index 94437888..60c98462 100644 --- a/plugins/fantasy-blitz/test_fantasy_blitz_manager.py +++ b/plugins/fantasy-blitz/test_fantasy_blitz_manager.py @@ -257,6 +257,50 @@ def test_vegas_content(frozen): assert {p["id"] for p in plugin._vegas_players()} <= wanted +def test_vegas_static_pause_walks_every_screen(frozen): + """Vegas calls display() once per pause and never again, with no mode. It + drew the top scorers' first card every time and skipped the other screens.""" + plugin, display, _ = make_plugin({"vegas_mode": "static"}) + plugin.update() + assert plugin.get_vegas_participation() == "pause" + playlist = [screen for screen, _items in plugin._playlist()] + assert len(playlist) > 3 + shown, frames = [], [] + for turn in range(len(playlist)): + frozen.tick(5) # each pass is a separate pause + assert plugin.display(force_clear=True) is True + shown.append(plugin._playlist()[plugin._pause_turn % len(playlist)][0]) + assert plugin.get_display_duration() in (plugin.item_seconds, manager_mod.PAGE_SECONDS) + frames.append(display.image.copy().tobytes()) + assert shown == playlist + assert len(set(frames)) == len(frames) + + +def test_vegas_static_pause_is_stable_within_one_pause(frozen): + plugin, _, _ = make_plugin({"vegas_mode": "static"}) + plugin.update() + plugin.display(force_clear=True) + turn = plugin._pause_turn + plugin.display(force_clear=True) + assert plugin._pause_turn == turn + + +def test_normal_rotation_is_unaffected_by_a_static_setting(frozen): + plugin, _, _ = make_plugin({"vegas_mode": "static"}) + plugin.update() + assert plugin.display(force_clear=True, display_mode="fantasy_leaderboard") is True + assert plugin._current_screen == "leaderboard" + assert plugin.get_display_duration() == 2 * manager_mod.PAGE_SECONDS + + +def test_vegas_fixed_scrolls_panel_sized_blocks(frozen): + plugin, _, _ = make_plugin({"vegas_mode": "fixed"}, size=(128, 64)) + plugin.update() + blocks = plugin.get_vegas_content() + assert blocks and all(block.size == (128, 64) for block in blocks) + assert len(blocks) > 3 # more than the ticker's title plus its scorers + + def test_espn_fallback_when_sleeper_is_down(frozen): fixture = copy.deepcopy(FIXTURE) fixture.pop(f"{ID}:stats:2026:2")