mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-09 16:46:37 +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
|
`render_vegas_strip()` and `render_vegas_timeline()` in
|
||||||
`src/plugin_system/testing/vegas.py`; the join is now
|
`src/plugin_system/testing/vegas.py`; the join is now
|
||||||
`render_pipeline.join_plugin_rows()`.
|
`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
|
### Scrolling
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
"plugin_rotation_order": [],
|
"plugin_rotation_order": [],
|
||||||
"use_short_date_format": true,
|
"use_short_date_format": true,
|
||||||
"vegas_scroll": {
|
"vegas_scroll": {
|
||||||
"live_in_ticker": false,
|
"live_in_ticker": true,
|
||||||
"live_weight": 3,
|
"live_weight": 3,
|
||||||
"favorite_live_weight": 5,
|
"favorite_live_weight": 5,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|||||||
@@ -75,17 +75,31 @@ total. See the full list in
|
|||||||
|
|
||||||
### Live Content in the Ticker
|
### Live Content in the Ticker
|
||||||
|
|
||||||
By default, live content **preempts** Vegas mode: while any plugin reports
|
By default (since 3.8.0) live content **stays in the ticker** and takes
|
||||||
live priority, the display controller refuses to run the ticker and shows
|
**extra turns inside it**, and a scoreboard that supports live cards updates
|
||||||
that plugin's full-screen display instead. You get a big readable scoreboard,
|
the score on a card already crossing the screen (`live_refresh`, "Update live
|
||||||
but the marquee stops entirely for the duration of the game.
|
content while it scrolls").
|
||||||
|
|
||||||
Set `live_in_ticker` to keep the ticker running and let live content take
|
To get the old behaviour back -- live content **preempts** Vegas mode: while
|
||||||
**extra turns inside it** instead:
|
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
|
```json
|
||||||
"vegas_scroll": {
|
"vegas_scroll": {
|
||||||
"live_in_ticker": true,
|
|
||||||
"live_weight": 3,
|
"live_weight": 3,
|
||||||
"favorite_live_weight": 5
|
"favorite_live_weight": 5
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
|||||||
| `max_cycle_duration` | int, `240` |
|
| `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) |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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.
|
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
|
3. **Live elements:** the plugin API, the records, the worker and in-place
|
||||||
patches, with the sports scoreboards and the flight map adopting it.
|
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
|
`display.vegas_scroll.offscreen_prefetch` (default `true`) restores the
|
||||||
deferred path when `false`, and `display.vegas_scroll.live_refresh` (default
|
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
|
weight calculation is broken still gets `live_weight` for a game that really
|
||||||
is live, rather than being demoted to 1.
|
is live, rather than being demoted to 1.
|
||||||
|
|
||||||
Only consulted when the user has set `vegas_scroll.live_in_ticker`. With the
|
Only consulted while `vegas_scroll.live_in_ticker` is on (the default since
|
||||||
default (`false`) live content preempts Vegas entirely and there is no ticker
|
3.8.0). With it off live content preempts Vegas entirely and there is no
|
||||||
to be weighted within. See
|
ticker to be weighted within. See
|
||||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
||||||
|
|
||||||
### Vegas scroll hooks
|
### Vegas scroll hooks
|
||||||
|
|||||||
+54
-3
@@ -200,12 +200,21 @@ def api_plugin_defaults(plugin_id):
|
|||||||
return jsonify({'defaults': defaults})
|
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,
|
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.
|
"""Render one plugin at one size. Returns the /api/render response dict.
|
||||||
|
|
||||||
A fresh plugin instance per call, mirroring the safety harness, so sizes
|
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.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||||
from src.plugin_system.plugin_loader import PluginLoader
|
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)
|
logger.warning("update() raised for plugin %s", plugin_id, exc_info=True)
|
||||||
warnings.append(f"update() raised: {type(e).__name__} — see server log")
|
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()
|
# Run display()
|
||||||
try:
|
try:
|
||||||
plugin_instance.display(force_clear=True)
|
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]:
|
def _trusted_plugin_dir(plugin_dir: Path) -> Optional[Path]:
|
||||||
"""Re-derive a plugin directory from the search dirs' own listings.
|
"""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):
|
if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
|
||||||
return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
|
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:
|
try:
|
||||||
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
||||||
except LookupError:
|
except LookupError:
|
||||||
@@ -345,7 +396,7 @@ def api_render():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = _render_once(data['plugin_id'], plugin_dir, manifest, config,
|
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:
|
except Exception:
|
||||||
app.logger.exception('plugin load failed during render')
|
app.logger.exception('plugin load failed during render')
|
||||||
return jsonify({'error': 'Failed to load plugin; see server log'}), 500
|
return jsonify({'error': 'Failed to load plugin; see server log'}), 500
|
||||||
|
|||||||
@@ -247,6 +247,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</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 -->
|
<!-- Render buttons -->
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button onclick="renderPlugin()" id="renderBtn"
|
<button onclick="renderPlugin()" id="renderBtn"
|
||||||
@@ -489,6 +502,7 @@
|
|||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
mock_data: mockData,
|
mock_data: mockData,
|
||||||
|
vegas: document.getElementById('viewSelect').value || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -510,8 +524,11 @@
|
|||||||
updateZoom();
|
updateZoom();
|
||||||
|
|
||||||
// Show render time
|
// Show render time
|
||||||
|
const live = data.live_elements;
|
||||||
document.getElementById('renderTimeText').textContent =
|
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
|
// Show warnings/errors
|
||||||
showMessages(data.errors || [], data.warnings || []);
|
showMessages(data.errors || [], data.warnings || []);
|
||||||
|
|||||||
+45
-3
@@ -451,8 +451,10 @@ class ConfigManager:
|
|||||||
template_config = json.load(f)
|
template_config = json.load(f)
|
||||||
|
|
||||||
# Check if migration is needed
|
# Check if migration is needed
|
||||||
if self._config_needs_migration(self.config, template_config):
|
needs_merge = self._config_needs_migration(self.config, template_config)
|
||||||
self.logger.info("Config migration needed - adding new configuration items with defaults")
|
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
|
# Create backup of current config
|
||||||
backup_path = f"{self.config_path}.backup"
|
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)}")
|
self.logger.info(f"Created backup of current config at {os.path.abspath(backup_path)}")
|
||||||
|
|
||||||
# Merge template defaults into current config
|
# 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
|
# save_config_atomic strips the merged secrets back out and
|
||||||
# keeps the file's owner and mode.
|
# keeps the file's owner and mode.
|
||||||
@@ -482,6 +486,44 @@ class ConfigManager:
|
|||||||
self.logger.error(f"Error during config migration: {e}")
|
self.logger.error(f"Error during config migration: {e}")
|
||||||
# Don't raise - continue with current config
|
# 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:
|
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."""
|
"""Check if config needs migration by comparing with template."""
|
||||||
return self._has_new_keys(current_config, template_config)
|
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."""
|
"""Whether live content should stay in the ticker instead of preempting it."""
|
||||||
coordinator = self.vegas_coordinator
|
coordinator = self.vegas_coordinator
|
||||||
config = getattr(coordinator, 'vegas_config', None)
|
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):
|
def _check_live_priority(self, advance=False):
|
||||||
"""Return the live-priority mode to display, or None if nothing is live.
|
"""Return the live-priority mode to display, or None if nothing is live.
|
||||||
|
|||||||
@@ -180,10 +180,13 @@ class VegasModeConfig:
|
|||||||
|
|
||||||
# --- Live content in the ticker -------------------------------------
|
# --- Live content in the ticker -------------------------------------
|
||||||
#
|
#
|
||||||
# By default a live game preempts Vegas entirely: the display controller
|
# By default live content stays in the marquee and takes extra turns
|
||||||
# refuses to run the ticker while any plugin reports live priority, and you
|
# within it, its cards updating while they scroll (live elements, below).
|
||||||
# get the full-screen scoreboard instead. Set live_in_ticker to keep the
|
# With live_in_ticker false a live game preempts Vegas entirely: the
|
||||||
# marquee running and let live content take extra turns within it.
|
# 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
|
# The rotation is otherwise a strict round robin -- every plugin appears
|
||||||
# exactly once per cycle -- so with a dozen plugins enabled a live score
|
# 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
|
# 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
|
# games still occupies one slot at a time, and rotates its own games within
|
||||||
# that slot using its own favorite_live_boost.
|
# 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
|
# Slots per cycle for a plugin reporting live content. 1 disables the boost
|
||||||
# and restores the plain round robin.
|
# and restores the plain round robin.
|
||||||
|
|||||||
@@ -134,6 +134,20 @@ class TestJsonPartialSaves:
|
|||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert saved['config']['display']['vegas_scroll']['live_refresh'] is True
|
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):
|
def test_double_sided_axis_only_keeps_enabled(self, api_v3_client, saved):
|
||||||
resp = _post_json(api_v3_client, {'double_sided_axis': 'horizontal'})
|
resp = _post_json(api_v3_client, {'double_sided_axis': 'horizontal'})
|
||||||
assert resp.status_code == 200, resp.get_json()
|
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:
|
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):
|
def test_scan_runs_at_most_once_per_interval(self, monkeypatch):
|
||||||
now = [1000.0]
|
now = [1000.0]
|
||||||
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: now[0])
|
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: now[0])
|
||||||
c = _coordinator()
|
c = _coordinator()
|
||||||
|
c.vegas_config.live_in_ticker = False
|
||||||
c._live_priority_check = MagicMock(return_value=None)
|
c._live_priority_check = MagicMock(return_value=None)
|
||||||
|
|
||||||
for _ in range(10):
|
for _ in range(10):
|
||||||
@@ -84,6 +87,7 @@ class TestLivePriorityThrottle:
|
|||||||
def test_live_content_still_pauses_vegas(self, monkeypatch):
|
def test_live_content_still_pauses_vegas(self, monkeypatch):
|
||||||
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: 1000.0)
|
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: 1000.0)
|
||||||
c = _coordinator()
|
c = _coordinator()
|
||||||
|
c.vegas_config.live_in_ticker = False
|
||||||
c._live_priority_check = MagicMock(return_value='nfl_live')
|
c._live_priority_check = MagicMock(return_value='nfl_live')
|
||||||
c.pause = MagicMock()
|
c.pause = MagicMock()
|
||||||
assert c.run_frame() is False
|
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):
|
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({})
|
coord = _coordinator({})
|
||||||
del coord.run_frame # the real one: it is what refuses frames while paused
|
del coord.run_frame # the real one: it is what refuses frames while paused
|
||||||
coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {
|
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.has_deferred.return_value = False
|
||||||
coord.render_pipeline.needs_extension.return_value = False
|
coord.render_pipeline.needs_extension.return_value = False
|
||||||
coord.render_pipeline.render_frame.return_value = True
|
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
|
refused to run the ticker while anything was live. With a dozen plugins
|
||||||
enabled that left a live score either absent or minutes stale.
|
enabled that left a live score either absent or minutes stale.
|
||||||
|
|
||||||
Two things change, both off by default. `live_in_ticker` keeps the marquee
|
Two things change. `live_in_ticker` keeps the marquee running instead of
|
||||||
running instead of yielding to a full-screen takeover, and the rotation is
|
yielding to a full-screen takeover (on by default since 3.8.0), and the
|
||||||
expanded by Smooth Weighted Round-Robin so a weighted plugin gets several
|
rotation is expanded by Smooth Weighted Round-Robin so a weighted plugin gets
|
||||||
slots per cycle, spaced through it rather than clumped.
|
several slots per cycle, spaced through it rather than clumped.
|
||||||
|
|
||||||
Weights are per plugin, not per game: a scoreboard showing four live games
|
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.
|
still occupies one slot at a time and rotates its own games within it.
|
||||||
@@ -288,9 +288,9 @@ class TestTheSchedule:
|
|||||||
|
|
||||||
|
|
||||||
class TestConfigParsing:
|
class TestConfigParsing:
|
||||||
def test_defaults_preserve_todays_behaviour(self):
|
def test_live_games_stay_in_the_ticker_by_default(self):
|
||||||
cfg = VegasModeConfig.from_config({})
|
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
|
assert cfg.live_weight == 3 and cfg.favorite_live_weight == 5
|
||||||
|
|
||||||
@pytest.mark.parametrize("given,expected", [(0, 1), (-4, 1), (99, 10), (4, 4)])
|
@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_intra_plugin_gap', 'vegas_render_width_pct',
|
||||||
'vegas_min_content_separation', 'vegas_min_cut_gap',
|
'vegas_min_content_separation', 'vegas_min_cut_gap',
|
||||||
'vegas_continuous_scroll', 'vegas_extend_threshold_screens',
|
'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 any(k in data for k in vegas_fields):
|
||||||
if 'display' not in current_config:
|
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, 'continuous_scroll', 'vegas_continuous_scroll')
|
||||||
_set_checkbox(vegas_config, 'smooth_scroll', 'vegas_smooth_scroll')
|
_set_checkbox(vegas_config, 'smooth_scroll', 'vegas_smooth_scroll')
|
||||||
_set_checkbox(vegas_config, 'live_refresh', 'vegas_live_refresh')
|
_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
|
# max_plugin_width_ratio is the one fractional setting, so it is
|
||||||
# handled outside the integer loop below.
|
# handled outside the integer loop below.
|
||||||
|
|||||||
@@ -579,6 +579,18 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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="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">
|
<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>
|
<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