Make manual pin permanent instead of a 5-minute timeout

"Show X now" now persists indefinitely (survives restarts too, it's in
config.json) instead of auto-expiring back to rotation after 5 minutes.
Implemented by reusing the existing rotation.enabled flag rather than a
separate scheduler-side timer: pinning a screen sets rotation.enabled=False
and records which screen in a new pinned_screen config field. Re-enabling
rotation -- either the "Resume automatic" button or just checking
"Enabled" again on the Rotation settings page -- clears the pin and
resumes normal cycling either way.

Navidrome priority is unchanged: still checked first, still preempts a
pin the instant something's playing, same as it already preempted
rotation.

Verified end-to-end: pin persists across many scheduler ticks (previously
would have reverted after 5 min), both resume paths (button and settings
checkbox) clear it correctly.
This commit is contained in:
Honey committed 2026-07-22 00:07:25 +02:00
1 parent c0192fca7a
commit 15c7c38d86
4 files changed
+49 -25

No files matched your search

+25
View File
@@ -42,6 +42,7 @@ class Config:
data["rotation"].setdefault("interval_minutes", DEFAULT_ROTATION["interval_minutes"]) data["rotation"].setdefault("interval_minutes", DEFAULT_ROTATION["interval_minutes"])
data["rotation"].setdefault("enabled", DEFAULT_ROTATION["enabled"]) data["rotation"].setdefault("enabled", DEFAULT_ROTATION["enabled"])
data.setdefault("navidrome_priority", True) data.setdefault("navidrome_priority", True)
data.setdefault("pinned_screen", None)
data.setdefault("settings", {}) data.setdefault("settings", {})
for screen_id, screen in screens.items(): for screen_id, screen in screens.items():
data["settings"].setdefault(screen_id, {}) data["settings"].setdefault(screen_id, {})
@@ -81,6 +82,30 @@ class Config:
def set_rotation(self, screens: list[str], interval_minutes: int, enabled: bool) -> None: def set_rotation(self, screens: list[str], interval_minutes: int, enabled: bool) -> None:
with self._lock: with self._lock:
self._data["rotation"] = {"screens": screens, "interval_minutes": interval_minutes, "enabled": enabled} self._data["rotation"] = {"screens": screens, "interval_minutes": interval_minutes, "enabled": enabled}
if enabled:
# Re-enabling rotation (e.g. via the settings checkbox)
# supersedes any manual pin -- that's the intended way to
# get back to automatic switching.
self._data["pinned_screen"] = None
self.save()
def get_pinned_screen(self) -> str | None:
with self._lock:
return self._data.get("pinned_screen")
def pin_screen(self, screen_id: str) -> None:
"""Pin a screen indefinitely -- turns rotation off so it doesn't
fight with the pin. Stays until the pin is explicitly cleared or
rotation is re-enabled (settings checkbox, or resume_rotation())."""
with self._lock:
self._data["pinned_screen"] = screen_id
self._data["rotation"]["enabled"] = False
self.save()
def resume_rotation(self) -> None:
with self._lock:
self._data["rotation"]["enabled"] = True
self._data["pinned_screen"] = None
self.save() self.save()
def get_navidrome_priority(self) -> bool: def get_navidrome_priority(self) -> bool:
+16 -18
View File
@@ -22,7 +22,6 @@ from PIL import Image
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TICK_SECONDS = 5 TICK_SECONDS = 5
MANUAL_OVERRIDE_SECONDS = 300 # how long "show this now" sticks before falling back to automatic
PREVIEW_PATH = Path(__file__).resolve().parent / "web" / "static" / "preview.png" PREVIEW_PATH = Path(__file__).resolve().parent / "web" / "static" / "preview.png"
@@ -49,8 +48,6 @@ class Scheduler:
self._rotation_switched_at = time.time() self._rotation_switched_at = time.time()
self._last_image_hash: str | None = None self._last_image_hash: str | None = None
self._manual_screen: str | None = None
self._manual_until: float = 0.0
self._force_once = False self._force_once = False
self._wake = threading.Event() self._wake = threading.Event()
@@ -80,23 +77,23 @@ class Scheduler:
self._wake.set() self._wake.set()
def request_switch(self, screen_id: str): def request_switch(self, screen_id: str):
"""Pin a screen for MANUAL_OVERRIDE_SECONDS. Navidrome priority can """Pin a screen indefinitely (persisted in config, turns rotation
still preempt it if something starts playing -- that's the one thing off). Navidrome priority can still preempt it if something starts
this app promises to always show reliably -- but rotation won't.""" playing -- that's the one thing this app promises to always show
self._manual_screen = screen_id reliably -- but rotation won't, until it's explicitly re-enabled."""
self._manual_until = time.time() + MANUAL_OVERRIDE_SECONDS self.config.pin_screen(screen_id)
self._wake.set() self._wake.set()
def request_resume_rotation(self): def request_resume_rotation(self):
self._manual_screen = None self.config.resume_rotation()
self._wake.set() self._wake.set()
def get_status(self) -> dict: def get_status(self) -> dict:
with self._status_lock: with self._status_lock:
status = dict(self._status) status = dict(self._status)
manual_active = bool(self._manual_screen) and time.time() < self._manual_until rotation = self.config.get_rotation()
status["manual_screen"] = self._manual_screen if manual_active else None pinned = self.config.get_pinned_screen()
status["manual_until"] = time.strftime("%H:%M:%S", time.localtime(self._manual_until)) if manual_active else None status["pinned_screen"] = pinned if (pinned and not rotation.get("enabled", True)) else None
return status return status
# ------------------------------------------------------------- internal # ------------------------------------------------------------- internal
@@ -144,18 +141,19 @@ class Scheduler:
Priority order: Navidrome-when-playing (if enabled) always wins -- Priority order: Navidrome-when-playing (if enabled) always wins --
that's the one thing this app promises to show reliably. Then a that's the one thing this app promises to show reliably. Then a
manual "show this now" pin, while it's still within its window. Then manual "show this now" pin, if rotation is off because of one (set
normal rotation. together in Config.pin_screen()). Then normal rotation.
""" """
if self.config.get_navidrome_priority() and "navidrome" in self.screens: if self.config.get_navidrome_priority() and "navidrome" in self.screens:
screen_id, state, checked = self._poll_screen("navidrome") screen_id, state, checked = self._poll_screen("navidrome")
if state is not None: if state is not None:
return screen_id, state, checked return screen_id, state, checked
if self._manual_screen: rotation = self.config.get_rotation()
if time.time() < self._manual_until: if not rotation.get("enabled", True):
return self._poll_screen(self._manual_screen) pinned = self.config.get_pinned_screen()
self._manual_screen = None # window expired if pinned and pinned in self.screens:
return self._poll_screen(pinned)
return self._rotation_screen() return self._rotation_screen()
+3 -3
View File
@@ -22,9 +22,9 @@
<span class="badge {{ 'on' if rotation.enabled else 'off' }}">{{ "on" if rotation.enabled else "off" }}</span> <span class="badge {{ 'on' if rotation.enabled else 'off' }}">{{ "on" if rotation.enabled else "off" }}</span>
{{ rotation.screens | join(" → ") }} (every {{ rotation.interval_minutes }} min) {{ rotation.screens | join(" → ") }} (every {{ rotation.interval_minutes }} min)
</dd> </dd>
{% if status.manual_screen %} {% if status.pinned_screen %}
<dt>Manual pin</dt> <dt>Manual pin</dt>
<dd><span class="badge on">{{ screens[status.manual_screen].display_name }}</span> until {{ status.manual_until }}</dd> <dd><span class="badge on">{{ screens[status.pinned_screen].display_name }}</span> -- fixed until resumed</dd>
{% endif %} {% endif %}
</dl> </dl>
</div> </div>
@@ -40,7 +40,7 @@
<button class="secondary" type="submit">Show {{ screen.display_name }}</button> <button class="secondary" type="submit">Show {{ screen.display_name }}</button>
</form> </form>
{% endfor %} {% endfor %}
{% if status.manual_screen %} {% if status.pinned_screen %}
<form class="inline" method="post" action="{{ url_for('resume_rotation') }}"> <form class="inline" method="post" action="{{ url_for('resume_rotation') }}">
<button class="secondary" type="submit">Resume automatic</button> <button class="secondary" type="submit">Resume automatic</button>
</form> </form>
+5 -4
View File
@@ -18,10 +18,11 @@
Enabled -- cycle through the screens below on a timer Enabled -- cycle through the screens below on a timer
</label> </label>
<p class="hint" style="margin: 0.6rem 0 1.1rem;"> <p class="hint" style="margin: 0.6rem 0 1.1rem;">
Off just pins to the first screen in the order below (still shown when Clicking "Show X now" on the Status page turns this off automatically
Navidrome priority is on but nothing's playing). "Show X now" buttons on and fixes the display to that screen until you turn rotation back on
the Status page still work either way, they just don't stick past the here (Navidrome priority above still applies on top of a pin, if
next check. enabled). With no pin and rotation off, it just sits on the first
screen in the order below.
</p> </p>
<div class="field"> <div class="field">
<label for="screens">Order</label> <label for="screens">Order</label>