Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
2 changes: 1 addition & 1 deletion plugins/fantasy-blitz/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion plugins/fantasy-blitz/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
}
}
106 changes: 101 additions & 5 deletions plugins/fantasy-blitz/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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"

Expand Down
8 changes: 7 additions & 1 deletion plugins/fantasy-blitz/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions plugins/fantasy-blitz/test_fantasy_blitz_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading