From 7f06cc9c3bc7e777b9140c8304a1fd855978e44c Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:27:16 -0400 Subject: [PATCH] feat(vegas): keep live games in the ticker by default (#699) * perf(timing): say which render-thread work a late frame followed The soak already says how often a moving frame reached the panel late, but not what the render thread was doing just before it. Vegas does two kinds of work there between frames -- building its strip (compose, extend) and, with live elements, patching changed pixels into it -- and deciding whether either is affordable needs their own numbers. - FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame. Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind; aggregate() still takes frames without ops. The file schema is unchanged. - Vegas tags compose and every strip extension (with the bytes it copied). - frame_soak prints an "after work" table: frames, late %, freezes and MB moved per kind, only when something tagged its work. - render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes / --patch-every / --patch-where (in-place column writes, as a live element update does) and --extend-every-screens / --extend-width (append + trim on a fixed cadence that holds the strip's width). No runtime behaviour changes: this is the measurement gate for live Vegas elements. Co-Authored-By: Claude Opus 5.5 * docs(changelog): note the frame-op attribution and bench modes Co-Authored-By: Claude Opus 5.5 * perf(scroll): build the strip's PIL image only when something reads it Every Vegas strip extension rebuilt ScrollHelper.cached_image from cached_array in full, twice (append, then trim), on the render thread: Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a Pi 4 (measured on ledpi), about two thirds of an extension's render-thread cost. Nothing on the frame path reads the image's pixels; every frame is cut from the array. cached_image is now a property. append_content and drop_scrolled_prefix defer it; the first read builds it from the array it started with and keeps it only if the strip has not changed meanwhile, so a sync push racing an extension cannot leave a stale image cached. Assigning cached_image stores exactly what was assigned, as before. has_strip() says whether there is a strip without building its image; the helper's frame path, Vegas and the adapter's scroll-cache invalidation use it. The strip is also no longer held in memory twice. In Vegas the image is now built only by a multi-display sync push. Co-Authored-By: Claude Opus 5.5 * feat(vegas): live elements -- a plugin API for content that changes while it scrolls Vegas bakes each plugin's pictures into one strip, so a card already on its way across the panel keeps what it showed when it was drawn. This adds the API and bookkeeping for content that can be updated in place; the worker that redraws and swaps it follows separately. No shipped plugin implements the hook yet, so nothing changes for users. Plugin API (core 3.8.0), all no-ops by default: - BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version, live, refresh_hz)]: named, fixed-width pieces of Vegas content. - BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free redraw for content that changes with time. - BasePlugin.notify_vegas_data_changed(): data that lands outside update(). - src/plugin_system/vegas_elements.py (VegasElement, re-exported from base_plugin). Core: - PluginAdapter asks a plugin that implements the hook for elements on the background fetch only (under its lock, on its own canvas); every other path keeps get_vegas_content(). Live elements are pinned (padded with content_padding, never trimmed), tagged with their key, digest and data epoch in Image.info so the existing cache and group plumbing carry them unchanged, and untagged if a width budget crops them. - RenderPipeline records where each live element lands (ElementRecord), in absolute strip columns a trim does not move; the block-start arithmetic is shared with the STATIC markers. - PluginManager update listeners (add/remove_update_listener, notify_data_changed): told the moment update() completes, not at the next ~4s Vegas poll. The coordinator uses one to move each plugin's data epoch on. - vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval, live_lead_screens; per-plugin core-owned vegas_live. Live elements are off under multi-display sync, in swap mode and with offscreen_prefetch off. - scripts/check_plugin.py checks the element contract (src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub is a working example. Co-Authored-By: Claude Opus 5.5 * feat(vegas): live elements update in place while they scroll One background worker (src/vegas_mode/live_worker.py) redraws a plugin's live elements when its data epoch moves on (update listener) or on their refresh_hz, nearest the screen first, and hands changed pixels lock-free to the render thread, which copies them into the strip between frames (RenderPipeline.apply_live_patches, ScrollHelper.patch_columns): at most four patches or two screens of bytes a frame, no drawing or locks there. The worker takes over group prefetch once a live element is placed, runs inside the render gate, and is supervised. Update tick 1s while live elements exist. Web UI switch for live_refresh. OFFSCREEN_RENDERING.md describes what was built and why SegmentStrip was not needed. Co-Authored-By: Claude Opus 5.5 * feat(sports): live Vegas cards for the scoreboards (shared layer) One live element per game, drawn only when what the card shows changes, so a score changes on a card already crossing the panel. The shared part, so each scoreboard adopts it in a few lines: - src/common/sports_vegas.py: game_key, game_fingerprint (the whole game dict, frozen: no drawn field can be missed), dedupe_games, VegasCardCache, StickyOdds (odds a live poll left out stay drawn), finished_games / with_finished_games (a game that just went final keeps its card, after its league's live games; one a heuristic only judged over keeps its live state, so a tied end of regulation never shows FINAL early). - SportsScrollDisplay.make_vegas_renderer() is the override point; build_vegas_elements() and SportsScrollDisplayManager .get_vegas_elements_for() do the rest. A card's version includes its teams' ranks, which the renderer draws from the rankings cache. - SportsLiveSharedMixin._record_finished_game() / finished_games_snapshot(): held for FINISHED_GAME_TTL after it leaves the live list. A sport that does not implement make_vegas_renderer keeps its ordinary Vegas content, so no scoreboard changes until it opts in. scripts/render_plugin.py --vegas renders a plugin's Vegas block as the ticker lays it out, and --timeline stacks it at successive moments as the ticker would update it in place; the join is now render_pipeline.join_plugin_rows(). Co-Authored-By: Claude Opus 5.5 * feat(vegas): keep live games in the ticker by default display.vegas_scroll.live_in_ticker now defaults to true: through a live game the marquee keeps running and the live scoreboard takes extra turns in it -- its cards updating in place while they scroll -- instead of the ticker giving way to the full-screen scoreboard. The new default would reach nobody on its own: every existing config holds an explicit false copied from the template (there was no control for it), and the template merge only adds missing keys. ConfigManager therefore turns a stored false on once, with a backup, and records live_in_ticker_migrated so a false chosen afterwards stays. The marker is never in the template. A "Keep live games in the ticker" checkbox under Vegas mode sets it. Tests that pin the full-screen takeover now say live_in_ticker=false. Co-Authored-By: Claude Opus 5.5 * refactor(sports): a default _determine_game_type on SportsScrollDisplay render_vegas_card looked the method up with getattr and a None default, which static analysis (Codacy) reports as calling something that may not be callable. The base class now has the default -- the card type from the game's state -- and the plugins that define their own override it as before. Co-Authored-By: Claude Opus 5.5 * fix: review follow-ups on the shared live-card layer - The reused Vegas renderer always gets the current rankings, empty included, so ranks cleared since are not kept drawn. - render_plugin.py: --timeline refuses --no-live (a timeline shows live elements changing), --timeline/--no-live need --vegas, and the Vegas paths create the output's directory like the display path does. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 9 ++ config/config.template.json | 2 +- docs/ADVANCED_FEATURES.md | 28 +++-- docs/CONFIG_REFERENCE.md | 2 +- docs/OFFSCREEN_RENDERING.md | 3 + docs/PLUGIN_API_REFERENCE.md | 6 +- src/config_manager.py | 48 +++++++- src/display_controller.py | 2 +- src/vegas_mode/config.py | 13 ++- test/test_api_v3_partial_main_save.py | 14 +++ test/test_config_live_in_ticker_migration.py | 108 ++++++++++++++++++ test/test_vegas_coordinator_config.py | 4 + test/test_vegas_coordinator_iteration.py | 9 +- test/test_vegas_live_weighting.py | 12 +- web_interface/blueprints/api_v3/config.py | 4 +- .../templates/v3/partials/display.html | 12 ++ 16 files changed, 246 insertions(+), 30 deletions(-) create mode 100644 test/test_config_live_in_ticker_migration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 97238819..0ba3bec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -406,6 +406,15 @@ read any of them: `render_vegas_strip()` and `render_vegas_timeline()` in `src/plugin_system/testing/vegas.py`; the join is now `render_pipeline.join_plugin_rows()`. +- **Behaviour change: live games stay in the Vegas ticker by default.** + `display.vegas_scroll.live_in_ticker` now defaults to `true`: the marquee + keeps running through a live game, which takes extra turns in it, instead + of giving way to the full-screen scoreboard. Existing configs all held the + old `false`, copied from the template, so the first start turns it on once + (`ConfigManager._migrate_live_in_ticker_default`; the previous config is + kept as `config.json.backup` and `live_in_ticker_migrated` records that it + ran). To keep the full-screen scoreboard, untick the new **Keep live games + in the ticker** under Vegas mode; a `false` set after the migration stays. ### Scrolling diff --git a/config/config.template.json b/config/config.template.json index 9f907428..98b0f30e 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -134,7 +134,7 @@ "plugin_rotation_order": [], "use_short_date_format": true, "vegas_scroll": { - "live_in_ticker": false, + "live_in_ticker": true, "live_weight": 3, "favorite_live_weight": 5, "enabled": false, diff --git a/docs/ADVANCED_FEATURES.md b/docs/ADVANCED_FEATURES.md index 3aed7ba5..a90cb8b3 100644 --- a/docs/ADVANCED_FEATURES.md +++ b/docs/ADVANCED_FEATURES.md @@ -75,17 +75,31 @@ total. See the full list in ### Live Content in the Ticker -By default, live content **preempts** Vegas mode: while any plugin reports -live priority, the display controller refuses to run the ticker and shows -that plugin's full-screen display instead. You get a big readable scoreboard, -but the marquee stops entirely for the duration of the game. +By default (since 3.8.0) live content **stays in the ticker** and takes +**extra turns inside it**, and a scoreboard that supports live cards updates +the score on a card already crossing the screen (`live_refresh`, "Update live +content while it scrolls"). -Set `live_in_ticker` to keep the ticker running and let live content take -**extra turns inside it** instead: +To get the old behaviour back -- live content **preempts** Vegas mode: while +any plugin reports live priority the ticker stops and that plugin's +full-screen display is shown instead -- untick **Keep live games in the +ticker** under Vegas mode, or set `live_in_ticker` to `false`: + +```json +"vegas_scroll": { + "live_in_ticker": false +} +``` + +Until 3.8.0 `false` was the default and every config held it, copied from +the template. The first start on 3.8.0 turns it on once (a backup of the +config is kept as `config.json.backup`, and `live_in_ticker_migrated` records +that it ran); a `false` set after that is left alone. + +The weights below apply while live content is in the ticker: ```json "vegas_scroll": { - "live_in_ticker": true, "live_weight": 3, "favorite_live_weight": 5 } diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index 90230dbe..31ae053e 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -152,7 +152,7 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See | `max_cycle_duration` | int, `240` | | `frame_based_scrolling` | bool, `true` — does not step or set a frame rate; motion is by elapsed time either way. When `true`, `scroll_speed` passes through a clamp of 0.1–5 px per `scroll_delay` (see next row) | | `scroll_delay` | float, `0.02` — not a frame period. Only used with `frame_based_scrolling`: the applied speed is `clamp(scroll_speed × scroll_delay, 0.1, 5) / scroll_delay` px/s, so at `0.02` speeds under 5 px/s run at 5, and at `0.001` nothing runs slower than 100 px/s | -| `live_in_ticker` | bool, `false` — keep scrolling during live games instead of handing the display to a full-screen scoreboard | +| `live_in_ticker` | bool, `true` — keep scrolling during live games instead of handing the display to a full-screen scoreboard. `false` was the default before 3.8.0; the first start on 3.8.0 turns a stored `false` on once and sets `live_in_ticker_migrated` | | `live_weight` | int, `3` (1–10) — slots per cycle for a plugin with live content | | `favorite_live_weight` | int, `5` (1–10) — slots per cycle when a plugin reports a favorite team is live | diff --git a/docs/OFFSCREEN_RENDERING.md b/docs/OFFSCREEN_RENDERING.md index d84fb442..94551599 100644 --- a/docs/OFFSCREEN_RENDERING.md +++ b/docs/OFFSCREEN_RENDERING.md @@ -336,6 +336,9 @@ to rebuild it twice, 1.7-3.8 ms each on a Pi 4). The `extend` row of work" table), and extensions no longer rebuilding the strip's PIL copy. 3. **Live elements:** the plugin API, the records, the worker and in-place patches, with the sports scoreboards and the flight map adopting it. +4. **Live games in the ticker by default:** `live_in_ticker` true, so a live + game's cards update in the marquee instead of the full-screen scoreboard + replacing it; existing configs are switched once (`ConfigManager`). `display.vegas_scroll.offscreen_prefetch` (default `true`) restores the deferred path when `false`, and `display.vegas_scroll.live_refresh` (default diff --git a/docs/PLUGIN_API_REFERENCE.md b/docs/PLUGIN_API_REFERENCE.md index 92f587da..f97c3a6d 100644 --- a/docs/PLUGIN_API_REFERENCE.md +++ b/docs/PLUGIN_API_REFERENCE.md @@ -308,9 +308,9 @@ the core then falls back to its own live-content check — so a plugin whose weight calculation is broken still gets `live_weight` for a game that really is live, rather than being demoted to 1. -Only consulted when the user has set `vegas_scroll.live_in_ticker`. With the -default (`false`) live content preempts Vegas entirely and there is no ticker -to be weighted within. See +Only consulted while `vegas_scroll.live_in_ticker` is on (the default since +3.8.0). With it off live content preempts Vegas entirely and there is no +ticker to be weighted within. See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md#live-content-in-the-ticker). ### Vegas scroll hooks diff --git a/src/config_manager.py b/src/config_manager.py index 292f6026..dd7982a3 100644 --- a/src/config_manager.py +++ b/src/config_manager.py @@ -451,8 +451,10 @@ class ConfigManager: template_config = json.load(f) # Check if migration is needed - if self._config_needs_migration(self.config, template_config): - self.logger.info("Config migration needed - adding new configuration items with defaults") + needs_merge = self._config_needs_migration(self.config, template_config) + if needs_merge or self._live_in_ticker_needs_migration(): + if needs_merge: + self.logger.info("Config migration needed - adding new configuration items with defaults") # Create backup of current config backup_path = f"{self.config_path}.backup" @@ -461,7 +463,9 @@ class ConfigManager: self.logger.info(f"Created backup of current config at {os.path.abspath(backup_path)}") # Merge template defaults into current config - self._merge_template_defaults(self.config, template_config) + if needs_merge: + self._merge_template_defaults(self.config, template_config) + self._migrate_live_in_ticker_default() # save_config_atomic strips the merged secrets back out and # keeps the file's owner and mode. @@ -482,6 +486,44 @@ class ConfigManager: self.logger.error(f"Error during config migration: {e}") # Don't raise - continue with current config + #: Set in display.vegas_scroll once _migrate_live_in_ticker_default() has + #: run. Never in the template: the template merge would add it first, and + #: the flip would then never run. + LIVE_IN_TICKER_MARKER = 'live_in_ticker_migrated' + + def _vegas_scroll_section(self) -> Optional[Dict[str, Any]]: + display = self.config.get('display') + vegas = display.get('vegas_scroll') if isinstance(display, dict) else None + return vegas if isinstance(vegas, dict) else None + + def _live_in_ticker_needs_migration(self) -> bool: + vegas = self._vegas_scroll_section() + return vegas is not None and not vegas.get(self.LIVE_IN_TICKER_MARKER) + + def _migrate_live_in_ticker_default(self) -> None: + """Turn on live_in_ticker for a config that only ever had the old default. Once. + + LEDMatrix 3.8.0 makes ``display.vegas_scroll.live_in_ticker`` true: + live games stay in the Vegas ticker, their cards updating while they + scroll, instead of the ticker giving way to the full-screen + scoreboard. Every existing config holds an explicit ``false`` copied + from the template -- there was no control for it -- and the template + merge only adds missing keys, so the new default would reach nobody. + This rewrites that ``false`` once and marks the config, so a + ``false`` chosen afterwards (the Vegas checkbox, or by hand) stays. + """ + vegas = self._vegas_scroll_section() + if vegas is None or vegas.get(self.LIVE_IN_TICKER_MARKER): + return + vegas[self.LIVE_IN_TICKER_MARKER] = True + if vegas.get('live_in_ticker') is False: + vegas['live_in_ticker'] = True + self.logger.info( + "Vegas mode now keeps live games in the ticker (the new default): " + "display.vegas_scroll.live_in_ticker turned on, once. Untick " + "\"Keep live games in the ticker\" under Vegas mode for the " + "full-screen scoreboard.") + def _config_needs_migration(self, current_config: Dict[str, Any], template_config: Dict[str, Any]) -> bool: """Check if config needs migration by comparing with template.""" return self._has_new_keys(current_config, template_config) diff --git a/src/display_controller.py b/src/display_controller.py index 883b16d6..dee04f21 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -2206,7 +2206,7 @@ class DisplayController: """Whether live content should stay in the ticker instead of preempting it.""" coordinator = self.vegas_coordinator config = getattr(coordinator, 'vegas_config', None) - return bool(getattr(config, 'live_in_ticker', False)) + return bool(getattr(config, 'live_in_ticker', True)) def _check_live_priority(self, advance=False): """Return the live-priority mode to display, or None if nothing is live. diff --git a/src/vegas_mode/config.py b/src/vegas_mode/config.py index 31de341a..affc7a15 100644 --- a/src/vegas_mode/config.py +++ b/src/vegas_mode/config.py @@ -180,10 +180,13 @@ class VegasModeConfig: # --- Live content in the ticker ------------------------------------- # - # By default a live game preempts Vegas entirely: the display controller - # refuses to run the ticker while any plugin reports live priority, and you - # get the full-screen scoreboard instead. Set live_in_ticker to keep the - # marquee running and let live content take extra turns within it. + # By default live content stays in the marquee and takes extra turns + # within it, its cards updating while they scroll (live elements, below). + # With live_in_ticker false a live game preempts Vegas entirely: the + # display controller refuses to run the ticker while any plugin reports + # live priority, and you get the full-screen scoreboard instead. (False + # was the default until 3.8.0; ConfigManager turns it on once for configs + # that still hold the old default.) # # The rotation is otherwise a strict round robin -- every plugin appears # exactly once per cycle -- so with a dozen plugins enabled a live score @@ -193,7 +196,7 @@ class VegasModeConfig: # Weights are per plugin, not per game: a scoreboard showing four live # games still occupies one slot at a time, and rotates its own games within # that slot using its own favorite_live_boost. - live_in_ticker: bool = False + live_in_ticker: bool = True # Slots per cycle for a plugin reporting live content. 1 disables the boost # and restores the plain round robin. diff --git a/test/test_api_v3_partial_main_save.py b/test/test_api_v3_partial_main_save.py index c5feb0df..87c830cc 100644 --- a/test/test_api_v3_partial_main_save.py +++ b/test/test_api_v3_partial_main_save.py @@ -134,6 +134,20 @@ class TestJsonPartialSaves: assert resp.status_code == 200, resp.get_json() assert saved['config']['display']['vegas_scroll']['live_refresh'] is True + def test_live_games_can_be_kept_in_the_ticker_or_not(self, api_v3_client, saved): + resp = _post_json(api_v3_client, {'vegas_live_in_ticker': False}) + assert resp.status_code == 200, resp.get_json() + assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is False + # An unticked box is absent from a submitted form: that is false too. + resp = _post_json(api_v3_client, {'__form_section': 'display', + 'vegas_scroll_speed': '50', + 'vegas_live_in_ticker': 'on'}) + assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is True + resp = _post_json(api_v3_client, {'__form_section': 'display', + 'vegas_scroll_speed': '50'}) + assert resp.status_code == 200, resp.get_json() + assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is False + def test_double_sided_axis_only_keeps_enabled(self, api_v3_client, saved): resp = _post_json(api_v3_client, {'double_sided_axis': 'horizontal'}) assert resp.status_code == 200, resp.get_json() diff --git a/test/test_config_live_in_ticker_migration.py b/test/test_config_live_in_ticker_migration.py new file mode 100644 index 00000000..fd8c6175 --- /dev/null +++ b/test/test_config_live_in_ticker_migration.py @@ -0,0 +1,108 @@ +"""live_in_ticker's new default reaches existing installs, once (src/config_manager.py). + +3.8.0 makes display.vegas_scroll.live_in_ticker true. Every existing config +holds an explicit false copied from the template, which the template merge +never touches (it only adds missing keys), so ConfigManager turns that false +on once and marks the config. A false chosen after that -- the new checkbox, +or by hand -- must stay false. +""" + +import json +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from src.config_manager import ConfigManager # noqa: E402 + +REPO = Path(__file__).resolve().parent.parent +MARKER = ConfigManager.LIVE_IN_TICKER_MARKER + + +@pytest.fixture +def files(tmp_path): + template = {"display": {"brightness": 90, + "vegas_scroll": {"enabled": False, "live_in_ticker": True}}} + paths = {name: tmp_path / f"{name}.json" for name in ("config", "secrets", "template")} + paths["template"].write_text(json.dumps(template)) + paths["secrets"].write_text("{}") + return paths + + +def _load(files, config): + files["config"].write_text(json.dumps(config)) + manager = ConfigManager(config_path=str(files["config"]), + secrets_path=str(files["secrets"])) + manager.template_path = str(files["template"]) + manager.load_config() + return manager, json.loads(files["config"].read_text()) + + +def _vegas(saved): + return saved["display"]["vegas_scroll"] + + +def test_the_old_default_is_turned_on_once_and_marked(files): + old = {"display": {"brightness": 90, + "vegas_scroll": {"enabled": True, "live_in_ticker": False}}} + manager, saved = _load(files, old) + assert _vegas(saved)["live_in_ticker"] is True + assert _vegas(saved)[MARKER] is True + assert _vegas(manager.config)["live_in_ticker"] is True + # The config as it was is kept beside it. + backup = json.loads(Path(f"{files['config']}.backup").read_text()) + assert _vegas(backup)["live_in_ticker"] is False + + +def test_false_chosen_after_the_migration_stays_false(files): + chosen = {"display": {"brightness": 90, + "vegas_scroll": {"enabled": True, "live_in_ticker": False, + MARKER: True}}} + _manager, saved = _load(files, chosen) + assert _vegas(saved)["live_in_ticker"] is False + + +def test_a_marked_config_is_not_written_again(files): + marked = {"display": {"brightness": 90, + "vegas_scroll": {"enabled": True, "live_in_ticker": True, + MARKER: True}}} + files["config"].write_text(json.dumps(marked)) + before = files["config"].stat().st_mtime_ns + manager = ConfigManager(config_path=str(files["config"]), + secrets_path=str(files["secrets"])) + manager.template_path = str(files["template"]) + manager.load_config() + assert files["config"].stat().st_mtime_ns == before + assert not Path(f"{files['config']}.backup").exists() + + +def test_a_config_without_vegas_settings_gets_the_new_default(files): + _manager, saved = _load(files, {"display": {"brightness": 90}}) + assert _vegas(saved)["live_in_ticker"] is True + assert _vegas(saved)[MARKER] is True + + +def test_true_already_is_only_marked(files): + on = {"display": {"brightness": 90, + "vegas_scroll": {"enabled": True, "live_in_ticker": True}}} + _manager, saved = _load(files, on) + assert _vegas(saved)["live_in_ticker"] is True and _vegas(saved)[MARKER] is True + + +def test_a_second_load_keeps_a_false_set_in_between(files): + old = {"display": {"brightness": 90, + "vegas_scroll": {"enabled": True, "live_in_ticker": False}}} + _manager, saved = _load(files, old) + _vegas(saved)["live_in_ticker"] = False # the checkbox, unticked + _manager, saved = _load(files, saved) + assert _vegas(saved)["live_in_ticker"] is False + + +def test_the_shipped_template_has_the_new_default_and_never_the_marker(): + template = json.loads((REPO / "config" / "config.template.json").read_text(encoding="utf-8")) + vegas = template["display"]["vegas_scroll"] + assert vegas["live_in_ticker"] is True + assert MARKER not in vegas diff --git a/test/test_vegas_coordinator_config.py b/test/test_vegas_coordinator_config.py index 34caa07b..c5b1263e 100644 --- a/test/test_vegas_coordinator_config.py +++ b/test/test_vegas_coordinator_config.py @@ -67,10 +67,13 @@ class TestConfigWhileStopped: class TestLivePriorityThrottle: + """The full-screen takeover's live scan: live_in_ticker off, no longer the default.""" + def test_scan_runs_at_most_once_per_interval(self, monkeypatch): now = [1000.0] monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: now[0]) c = _coordinator() + c.vegas_config.live_in_ticker = False c._live_priority_check = MagicMock(return_value=None) for _ in range(10): @@ -84,6 +87,7 @@ class TestLivePriorityThrottle: def test_live_content_still_pauses_vegas(self, monkeypatch): monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: 1000.0) c = _coordinator() + c.vegas_config.live_in_ticker = False c._live_priority_check = MagicMock(return_value='nfl_live') c.pause = MagicMock() assert c.run_frame() is False diff --git a/test/test_vegas_coordinator_iteration.py b/test/test_vegas_coordinator_iteration.py index f56d227b..8e73909a 100644 --- a/test/test_vegas_coordinator_iteration.py +++ b/test/test_vegas_coordinator_iteration.py @@ -58,11 +58,16 @@ def test_an_iteration_does_not_poll_every_plugin_for_its_mode(caplog): def _live_coordinator(live): - """A coordinator running the real run_frame(), with a switchable live check.""" + """A coordinator running the real run_frame(), with a switchable live check. + + live_in_ticker off: these pin the full-screen takeover, which is no longer + the default. + """ coord = _coordinator({}) del coord.run_frame # the real one: it is what refuses frames while paused coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': { - 'enabled': True, 'max_cycle_duration': 0, 'continuous_scroll': True}}}) + 'enabled': True, 'max_cycle_duration': 0, 'continuous_scroll': True, + 'live_in_ticker': False}}}) coord.render_pipeline.has_deferred.return_value = False coord.render_pipeline.needs_extension.return_value = False coord.render_pipeline.render_frame.return_value = True diff --git a/test/test_vegas_live_weighting.py b/test/test_vegas_live_weighting.py index 58ef5214..81ea719f 100644 --- a/test/test_vegas_live_weighting.py +++ b/test/test_vegas_live_weighting.py @@ -5,10 +5,10 @@ live content did not appear in it at all, because the display controller refused to run the ticker while anything was live. With a dozen plugins enabled that left a live score either absent or minutes stale. -Two things change, both off by default. `live_in_ticker` keeps the marquee -running instead of yielding to a full-screen takeover, and the rotation is -expanded by Smooth Weighted Round-Robin so a weighted plugin gets several -slots per cycle, spaced through it rather than clumped. +Two things change. `live_in_ticker` keeps the marquee running instead of +yielding to a full-screen takeover (on by default since 3.8.0), and the +rotation is expanded by Smooth Weighted Round-Robin so a weighted plugin gets +several slots per cycle, spaced through it rather than clumped. Weights are per plugin, not per game: a scoreboard showing four live games still occupies one slot at a time and rotates its own games within it. @@ -288,9 +288,9 @@ class TestTheSchedule: class TestConfigParsing: - def test_defaults_preserve_todays_behaviour(self): + def test_live_games_stay_in_the_ticker_by_default(self): cfg = VegasModeConfig.from_config({}) - assert cfg.live_in_ticker is False + assert cfg.live_in_ticker is True assert cfg.live_weight == 3 and cfg.favorite_live_weight == 5 @pytest.mark.parametrize("given,expected", [(0, 1), (-4, 1), (99, 10), (4, 4)]) diff --git a/web_interface/blueprints/api_v3/config.py b/web_interface/blueprints/api_v3/config.py index 35515a20..9f50891d 100644 --- a/web_interface/blueprints/api_v3/config.py +++ b/web_interface/blueprints/api_v3/config.py @@ -802,7 +802,8 @@ def save_main_config(): 'vegas_intra_plugin_gap', 'vegas_render_width_pct', 'vegas_min_content_separation', 'vegas_min_cut_gap', 'vegas_continuous_scroll', 'vegas_extend_threshold_screens', - 'vegas_smooth_scroll', 'vegas_overflow_mode', 'vegas_live_refresh'] + 'vegas_smooth_scroll', 'vegas_overflow_mode', 'vegas_live_refresh', + 'vegas_live_in_ticker'] if any(k in data for k in vegas_fields): if 'display' not in current_config: @@ -823,6 +824,7 @@ def save_main_config(): _set_checkbox(vegas_config, 'continuous_scroll', 'vegas_continuous_scroll') _set_checkbox(vegas_config, 'smooth_scroll', 'vegas_smooth_scroll') _set_checkbox(vegas_config, 'live_refresh', 'vegas_live_refresh') + _set_checkbox(vegas_config, 'live_in_ticker', 'vegas_live_in_ticker') # max_plugin_width_ratio is the one fractional setting, so it is # handled outside the integer loop below. diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html index 42044ecc..a1da0217 100644 --- a/web_interface/templates/v3/partials/display.html +++ b/web_interface/templates/v3/partials/display.html @@ -579,6 +579,18 @@ +
+ +
+