mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 23:05:10 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfd67c7c8b | ||
|
|
7f06cc9c3b |
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+54
-3
@@ -200,12 +200,21 @@ def api_plugin_defaults(plugin_id):
|
||||
return jsonify({'defaults': defaults})
|
||||
|
||||
|
||||
#: /api/render "vegas" values: the plugin's block of the Vegas strip, built
|
||||
#: from its live elements (falling back to its Vegas content, as the ticker
|
||||
#: does) or from its ordinary Vegas content only.
|
||||
VEGAS_VIEWS = ('live', 'plain')
|
||||
|
||||
|
||||
def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, height,
|
||||
skip_update):
|
||||
skip_update, vegas=None):
|
||||
"""Render one plugin at one size. Returns the /api/render response dict.
|
||||
|
||||
A fresh plugin instance per call, mirroring the safety harness, so sizes
|
||||
never share state.
|
||||
never share state. With ``vegas`` set ('live' or 'plain') the image is the
|
||||
plugin's block of the Vegas strip instead of its display(), laid out by
|
||||
the ticker's own code (src/plugin_system/testing/vegas.py), and the
|
||||
response lists where each live element sits in it.
|
||||
"""
|
||||
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
@@ -243,6 +252,10 @@ def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, heig
|
||||
logger.warning("update() raised for plugin %s", plugin_id, exc_info=True)
|
||||
warnings.append(f"update() raised: {type(e).__name__} — see server log")
|
||||
|
||||
if vegas:
|
||||
return _vegas_response(plugin_id, plugin_instance, display_manager, vegas,
|
||||
start_time, errors, warnings)
|
||||
|
||||
# Run display()
|
||||
try:
|
||||
plugin_instance.display(force_clear=True)
|
||||
@@ -262,6 +275,40 @@ def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, heig
|
||||
}
|
||||
|
||||
|
||||
def _vegas_response(plugin_id, plugin_instance, display_manager, vegas, start_time,
|
||||
errors, warnings):
|
||||
"""The /api/render response for the Vegas strip view."""
|
||||
import base64
|
||||
import io
|
||||
|
||||
from src.plugin_system.testing.vegas import render_vegas_strip
|
||||
|
||||
block, layout = None, []
|
||||
try:
|
||||
block, layout = render_vegas_strip(plugin_instance, plugin_id, display_manager,
|
||||
live=(vegas == 'live'))
|
||||
except Exception as e:
|
||||
logger.warning("Vegas render raised for plugin %s", plugin_id, exc_info=True)
|
||||
errors.append(f"Vegas render raised: {type(e).__name__} — see server log")
|
||||
if block is None:
|
||||
if not errors:
|
||||
errors.append("The plugin has no Vegas content")
|
||||
block = display_manager.image
|
||||
elif vegas == 'live' and not layout:
|
||||
warnings.append("No live elements: this is the plugin's ordinary Vegas content")
|
||||
buffer = io.BytesIO()
|
||||
block.convert('RGB').save(buffer, format='PNG')
|
||||
return {
|
||||
'image': 'data:image/png;base64,' + base64.b64encode(buffer.getvalue()).decode('ascii'),
|
||||
'width': block.width,
|
||||
'height': block.height,
|
||||
'render_time_ms': round((time.time() - start_time) * 1000, 1),
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
'live_elements': [{'key': key, 'x': x, 'width': width} for x, key, width in layout],
|
||||
}
|
||||
|
||||
|
||||
def _trusted_plugin_dir(plugin_dir: Path) -> Optional[Path]:
|
||||
"""Re-derive a plugin directory from the search dirs' own listings.
|
||||
|
||||
@@ -333,6 +380,10 @@ def api_render():
|
||||
if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
|
||||
return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
|
||||
|
||||
vegas = data.get('vegas') or None
|
||||
if vegas is not None and vegas not in VEGAS_VIEWS:
|
||||
return jsonify({'error': f'vegas must be one of {", ".join(VEGAS_VIEWS)}'}), 400
|
||||
|
||||
try:
|
||||
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
||||
except LookupError:
|
||||
@@ -345,7 +396,7 @@ def api_render():
|
||||
|
||||
try:
|
||||
result = _render_once(data['plugin_id'], plugin_dir, manifest, config,
|
||||
mock_data, width, height, skip_update)
|
||||
mock_data, width, height, skip_update, vegas=vegas)
|
||||
except Exception:
|
||||
app.logger.exception('plugin load failed during render')
|
||||
return jsonify({'error': 'Failed to load plugin; see server log'}), 500
|
||||
|
||||
@@ -247,6 +247,19 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- What to render: the plugin's screen, or its block of the Vegas strip -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="viewSelect" class="text-xs whitespace-nowrap" style="color: var(--text-secondary);">View</label>
|
||||
<select id="viewSelect" onchange="onConfigChange()"
|
||||
class="flex-1 px-2 py-1.5 rounded-lg text-xs"
|
||||
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);"
|
||||
title="Vegas strip: the plugin's block of the Vegas ticker, laid out as the ticker lays it out">
|
||||
<option value="">Display</option>
|
||||
<option value="live">Vegas strip (live elements)</option>
|
||||
<option value="plain">Vegas strip (plain Vegas content)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Render buttons -->
|
||||
<div class="flex gap-2">
|
||||
<button onclick="renderPlugin()" id="renderBtn"
|
||||
@@ -489,6 +502,7 @@
|
||||
width: width,
|
||||
height: height,
|
||||
mock_data: mockData,
|
||||
vegas: document.getElementById('viewSelect').value || null,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -510,8 +524,11 @@
|
||||
updateZoom();
|
||||
|
||||
// Show render time
|
||||
const live = data.live_elements;
|
||||
document.getElementById('renderTimeText').textContent =
|
||||
`${data.render_time_ms}ms`;
|
||||
`${data.render_time_ms}ms` + (live ? ` · ${data.width}px strip, ` +
|
||||
`${live.length} live element(s)` +
|
||||
(live.length ? `: ${live.map(e => e.key).join(', ')}` : '') : '');
|
||||
|
||||
// Show warnings/errors
|
||||
showMessages(data.errors || [], data.warnings || []);
|
||||
|
||||
+45
-3
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,49 @@
|
||||
"""scripts/dev_server.py's Vegas strip view, on the stub fixture plugin."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
STUB = REPO / "test" / "fixtures" / "plugins" / "vegas-live-stub"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
spec = importlib.util.spec_from_file_location("dev_server_vegas_under_test",
|
||||
REPO / "scripts" / "dev_server.py")
|
||||
dev_server = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(dev_server)
|
||||
monkeypatch.setattr(dev_server, "find_plugin_dir", lambda pid: STUB)
|
||||
monkeypatch.setattr(dev_server, "_trusted_plugin_dir", lambda d: STUB)
|
||||
return dev_server.app.test_client()
|
||||
|
||||
|
||||
def _render(client, **extra):
|
||||
return client.post("/api/render", json={"plugin_id": "vegas-live-stub", "width": 192,
|
||||
"height": 48, "config": {"enabled": True},
|
||||
**extra})
|
||||
|
||||
|
||||
def test_the_live_strip_lists_its_elements(client):
|
||||
data = _render(client, vegas="live").get_json()
|
||||
assert data["image"].startswith("data:image/png;base64,")
|
||||
assert data["height"] == 48 and data["width"] > 192
|
||||
keys = [e["key"] for e in data["live_elements"]]
|
||||
assert keys[:2] == ["card:0", "card:1"] and "map" in keys
|
||||
assert not data["errors"]
|
||||
|
||||
|
||||
def test_the_plain_strip_has_no_live_elements(client):
|
||||
data = _render(client, vegas="plain").get_json()
|
||||
assert data["live_elements"] == [] and not data["errors"]
|
||||
|
||||
|
||||
def test_the_display_view_is_unchanged(client):
|
||||
data = _render(client).get_json()
|
||||
assert (data["width"], data["height"]) == (192, 48)
|
||||
assert "live_elements" not in data
|
||||
|
||||
|
||||
def test_an_unknown_view_is_refused(client):
|
||||
assert _render(client, vegas="sideways").status_code == 400
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -579,6 +579,18 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4" id="setting-display-vegas_live_in_ticker" data-setting-key="display.vegas_scroll.live_in_ticker">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
id="vegas_live_in_ticker"
|
||||
name="vegas_live_in_ticker"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('live_in_ticker', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Keep live games in the ticker{{ ui.help_tip('While a game is live, keep the ticker running and give the live scoreboard extra turns in it (Live Weight). Default: on.
|
||||
With this off a live game takes over the whole display with the full-screen scoreboard until it ends, and the ticker stops.', 'Live Games') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div class="form-group" id="setting-display-vegas_extend_threshold_screens" data-setting-key="display.vegas_scroll.extend_threshold_screens">
|
||||
<label for="vegas_extend_threshold_screens" class="block text-sm font-medium text-gray-700">Extend When (screens left){{ ui.help_tip('How much unscrolled content triggers loading the next group, measured in screen widths (1.0–10.0).\nDefault: 2. Higher loads earlier and leaves more slack, at the cost of holding more content in memory. Only applies when Continuous Scroll is on.', 'Extend Threshold') }}</label>
|
||||
|
||||
Reference in New Issue
Block a user