v1.2.0 - Weblink improvements, port UI fix, Wayland transition fix
- Add Port field to Settings UI (was hidden in config, not editable) - Fix double-port bug: skip appending :port if server_ip already contains one - Weblink: replace --kiosk with --start-fullscreen (fixes Wayland/Labwc window restoration after Chromium closes) - Weblink: inactivity watchdog - advance to next media only after N seconds of no touch (instead of fixed timer) - Weblink: detect Chromium process exit and advance immediately - Weblink: pre-warm Chromium in hidden off-screen window during previous media item to eliminate cold-start delay on transition - Weblink: Wayland-safe hide/show transition - hide content_area before Chromium, 200ms delay after kill before showing next media - Chromium flags: suppress GNOME keyring popup (--password-store=basic) - Suppress --disable-sync, --disable-background-networking dialogs
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
1782752385.7015185
|
||||
1782752485.750587
|
||||
+321
-10
@@ -3,6 +3,8 @@ Kivy Signage Player - Main Application
|
||||
Displays content from DigiServer playlists using Kivy framework
|
||||
"""
|
||||
|
||||
PLAYER_VERSION = "1.2.0"
|
||||
|
||||
import os
|
||||
import json
|
||||
import platform
|
||||
@@ -918,7 +920,10 @@ class SignagePlayer(Widget):
|
||||
self.playlist = []
|
||||
self.current_index = 0
|
||||
self.current_widget = None
|
||||
self._weblink_proc = None # Handle to the Chromium kiosk process for weblink items
|
||||
self._weblink_proc = None # Handle to the Chromium kiosk process for weblink items
|
||||
self._weblink_preload_proc = None # Hidden Chromium pre-warming the next weblink URL
|
||||
self._watchdog_stop = None # threading.Event to stop the inactivity watchdog
|
||||
self._weblink_watchdog_thread = None # Background thread monitoring touch inactivity
|
||||
self.is_playing = False
|
||||
self.is_paused = False
|
||||
self.auto_resume_event = None # Track scheduled auto-resume
|
||||
@@ -1277,11 +1282,13 @@ class SignagePlayer(Widget):
|
||||
self.current_index = 0
|
||||
self.play_current_media()
|
||||
|
||||
def play_current_media(self, force_reload=False):
|
||||
def play_current_media(self, force_reload=False, _after_weblink=False):
|
||||
"""Play the current media item
|
||||
|
||||
Args:
|
||||
force_reload: If True, clears image cache before loading (for edited images)
|
||||
_after_weblink: Internal flag — True when called after the weblink
|
||||
dismiss delay so we skip the delay logic a second time.
|
||||
"""
|
||||
# Don't play if paused (unless we're explicitly resuming)
|
||||
if self.is_paused:
|
||||
@@ -1300,14 +1307,72 @@ class SignagePlayer(Widget):
|
||||
|
||||
Logger.info(f"SignagePlayer: Playing item {self.current_index + 1}/{len(self.playlist)}: {file_name} ({duration}s)")
|
||||
|
||||
# Close any kiosk browser left over from a previous web-link item
|
||||
self._kill_weblink_process()
|
||||
# ── Weblink → media transition (Wayland-safe) ──────────────────
|
||||
# On Wayland (Labwc) Window.raise_window() is a no-op, so we must
|
||||
# ensure Chromium's fullscreen window is fully gone BEFORE we try
|
||||
# to render Kivy content. We:
|
||||
# 1. Stop watchdog + preload immediately
|
||||
# 2. Terminate Chromium (non-blocking)
|
||||
# 3. Hide the Kivy content_area so nothing stale is visible
|
||||
# 4. Wait 200 ms for the compositor to remove Chromium's window
|
||||
# 5. Show content_area + re-call play_current_media to render
|
||||
if not _after_weblink:
|
||||
proc = getattr(self, '_weblink_proc', None)
|
||||
if proc is not None:
|
||||
self._stop_inactivity_watchdog()
|
||||
self._kill_weblink_preload()
|
||||
self._weblink_proc = None
|
||||
|
||||
# Hide content while Chromium is still closing
|
||||
try:
|
||||
self.ids.content_area.opacity = 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Terminate Chromium
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception as exc:
|
||||
Logger.warning(f"SignagePlayer: weblink terminate: {exc}")
|
||||
|
||||
def _resume(dt):
|
||||
# Force-kill if still alive after the delay
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Restore content area and show Kivy window
|
||||
try:
|
||||
self.ids.content_area.opacity = 1
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
Window.show()
|
||||
Window.raise_window()
|
||||
except Exception:
|
||||
pass
|
||||
# Now render the actual media
|
||||
self.play_current_media(
|
||||
force_reload=force_reload, _after_weblink=True
|
||||
)
|
||||
|
||||
# 200 ms gives Labwc time to remove the fullscreen surface
|
||||
Clock.schedule_once(_resume, 0.2)
|
||||
return
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Handle web links before any file/path handling (no local file exists)
|
||||
if media_item.get('type') == 'weblink':
|
||||
Logger.debug("SignagePlayer: Media type: WEBLINK")
|
||||
self.ids.status_label.opacity = 0
|
||||
self._remove_current_widget()
|
||||
# Hide content_area — Chromium will cover it; avoids stale frame
|
||||
try:
|
||||
self.ids.content_area.opacity = 0
|
||||
except Exception:
|
||||
pass
|
||||
started = self.play_weblink(media_item.get('url', ''), duration)
|
||||
if started:
|
||||
self.consecutive_errors = 0
|
||||
@@ -1538,10 +1603,19 @@ class SignagePlayer(Widget):
|
||||
return False
|
||||
|
||||
try:
|
||||
Logger.info(f"SignagePlayer: Opening weblink in kiosk browser for {duration}s: {url}")
|
||||
Logger.info(f"SignagePlayer: Opening weblink in kiosk browser: {url}")
|
||||
Logger.info(f"SignagePlayer: Inactivity timeout set to {duration}s (touch resets the countdown)")
|
||||
# Kill the hidden pre-warm instance first; its work (binary in RAM,
|
||||
# page in disk cache) makes the kiosk relaunch below near-instant.
|
||||
self._kill_weblink_preload()
|
||||
self._weblink_proc = subprocess.Popen([
|
||||
browser,
|
||||
'--kiosk',
|
||||
# Use --start-fullscreen instead of --kiosk.
|
||||
# --kiosk requests Wayland's exclusive-fullscreen protocol which
|
||||
# prevents Labwc from restoring the previous window on close.
|
||||
# --start-fullscreen is a normal maximised window that the
|
||||
# compositor can un-stack without issues.
|
||||
'--start-fullscreen',
|
||||
'--app=' + url,
|
||||
'--noerrdialogs',
|
||||
'--disable-infobars',
|
||||
@@ -1549,25 +1623,184 @@ class SignagePlayer(Widget):
|
||||
'--no-first-run',
|
||||
'--disable-session-crashed-bubble',
|
||||
'--check-for-update-interval=31536000',
|
||||
# Suppress the GNOME keyring / wallet unlock popup
|
||||
'--password-store=basic',
|
||||
'--use-mock-keychain',
|
||||
# Prevent any other credential / sync dialogs
|
||||
'--disable-sync',
|
||||
'--disable-background-networking',
|
||||
'--no-default-browser-check',
|
||||
'--window-position=0,0',
|
||||
])
|
||||
|
||||
# Advance after the configured duration. The kiosk browser is closed
|
||||
# at the start of the next play_current_media() via _kill_weblink_process().
|
||||
# Unschedule any previous fixed timer — the watchdog thread takes
|
||||
# over and only advances after 'duration' seconds of NO touch activity.
|
||||
Clock.unschedule(self.next_media)
|
||||
Clock.schedule_once(self.next_media, duration)
|
||||
self._start_inactivity_watchdog(duration)
|
||||
|
||||
# Preload the next image so the transition after the weblink is smooth.
|
||||
self.preload_next_media()
|
||||
return True
|
||||
except Exception as e:
|
||||
Logger.error(f"SignagePlayer: Error opening weblink {url}: {e}")
|
||||
# Restore content area if launch failed
|
||||
try:
|
||||
self.ids.content_area.opacity = 1
|
||||
except Exception:
|
||||
pass
|
||||
self.consecutive_errors += 1
|
||||
self._weblink_proc = None
|
||||
self._skip_to_next_media()
|
||||
return False
|
||||
|
||||
def _start_inactivity_watchdog(self, duration):
|
||||
"""Start a background thread that monitors /dev/input/* for touch/key
|
||||
activity. The thread resets the idle counter on every event; when the
|
||||
screen has been idle for *duration* seconds it fires next_media().
|
||||
Works even while Chromium owns the display (raw device reads bypass the
|
||||
window-manager focus).
|
||||
Also fires immediately if Chromium exits on its own (e.g. user closes it).
|
||||
"""
|
||||
import glob
|
||||
import select
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Stop any previous watchdog cleanly before starting a new one.
|
||||
self._stop_inactivity_watchdog()
|
||||
|
||||
stop_event = threading.Event()
|
||||
self._watchdog_stop = stop_event
|
||||
weblink_proc = self._weblink_proc # snapshot so thread sees the right process
|
||||
|
||||
def watchdog():
|
||||
# Open every available input event device (touchscreen, mouse, kbd).
|
||||
devices = []
|
||||
for path in sorted(glob.glob('/dev/input/event*')):
|
||||
try:
|
||||
devices.append(open(path, 'rb')) # noqa: WPS515
|
||||
except (PermissionError, OSError) as exc:
|
||||
Logger.debug(f"SignagePlayer: Watchdog cannot open {path}: {exc}")
|
||||
|
||||
if not devices:
|
||||
# No input devices accessible → fall back to a plain fixed timer.
|
||||
Logger.warning(
|
||||
"SignagePlayer: Watchdog — no /dev/input devices accessible; "
|
||||
"falling back to fixed timer"
|
||||
)
|
||||
stop_event.wait(timeout=duration)
|
||||
if not stop_event.is_set():
|
||||
Clock.schedule_once(self.next_media, 0)
|
||||
return
|
||||
|
||||
Logger.info(
|
||||
f"SignagePlayer: Watchdog watching {len(devices)} input device(s), "
|
||||
f"idle threshold = {duration}s"
|
||||
)
|
||||
last_activity = time.monotonic()
|
||||
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
# If Chromium exited on its own (user closed it), advance immediately.
|
||||
if weblink_proc is not None and weblink_proc.poll() is not None:
|
||||
Logger.info(
|
||||
"SignagePlayer: Chromium exited — advancing to next media"
|
||||
)
|
||||
Clock.schedule_once(self.next_media, 0)
|
||||
break
|
||||
|
||||
idle = time.monotonic() - last_activity
|
||||
if idle >= duration:
|
||||
Logger.info(
|
||||
f"SignagePlayer: No touch for {duration}s — advancing to next media"
|
||||
)
|
||||
Clock.schedule_once(self.next_media, 0)
|
||||
break
|
||||
|
||||
# Wait up to 0.5 s for any raw input event.
|
||||
timeout = min(0.5, duration - idle)
|
||||
readable, _, _ = select.select(devices, [], [], timeout)
|
||||
|
||||
if readable:
|
||||
# Drain the data so the buffer doesn't fill up.
|
||||
for fd in readable:
|
||||
try:
|
||||
fd.read(24) # struct input_event = 24 bytes on 64-bit Linux
|
||||
except OSError:
|
||||
pass
|
||||
last_activity = time.monotonic()
|
||||
Logger.debug(
|
||||
"SignagePlayer: Touch/key detected — inactivity timer reset"
|
||||
)
|
||||
finally:
|
||||
for fd in devices:
|
||||
try:
|
||||
fd.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
self._weblink_watchdog_thread = threading.Thread(
|
||||
target=watchdog, daemon=True, name='weblink-watchdog'
|
||||
)
|
||||
self._weblink_watchdog_thread.start()
|
||||
|
||||
def _stop_inactivity_watchdog(self):
|
||||
"""Signal the watchdog thread to exit without firing next_media."""
|
||||
stop_event = getattr(self, '_watchdog_stop', None)
|
||||
if stop_event is not None:
|
||||
stop_event.set()
|
||||
self._watchdog_stop = None
|
||||
self._weblink_watchdog_thread = None
|
||||
|
||||
def _kill_weblink_after_frame(self):
|
||||
"""Gracefully transition away from a weblink item.
|
||||
|
||||
Stops the watchdog and preload immediately (so nothing fires a spurious
|
||||
next_media), clears self._weblink_proc so the slot is free for the next
|
||||
item, then schedules the actual Chromium termination for the *next Kivy
|
||||
frame*. By that time Kivy has already rendered the new media widget
|
||||
underneath Chromium, so when the browser window disappears the player
|
||||
content is instantly visible — no black flash.
|
||||
"""
|
||||
self._stop_inactivity_watchdog()
|
||||
self._kill_weblink_preload()
|
||||
|
||||
proc = self._weblink_proc # snapshot
|
||||
self._weblink_proc = None # free the slot right away
|
||||
|
||||
if proc is None or proc.poll() is not None:
|
||||
# Nothing to kill — still raise the window in case it got buried.
|
||||
try:
|
||||
Window.raise_window()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
def _do_kill(dt):
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
Logger.debug("SignagePlayer: Closed weblink kiosk browser (deferred)")
|
||||
except Exception as exc:
|
||||
Logger.warning(f"SignagePlayer: Error in deferred weblink kill: {exc}")
|
||||
# Bring Kivy window to front now that Chromium is gone.
|
||||
try:
|
||||
Window.raise_window()
|
||||
except Exception as exc:
|
||||
Logger.debug(f"SignagePlayer: raise_window failed (non-fatal): {exc}")
|
||||
|
||||
Clock.schedule_once(_do_kill, 0)
|
||||
|
||||
def _kill_weblink_process(self):
|
||||
"""Terminate the kiosk browser process if one is running."""
|
||||
"""Terminate the kiosk browser, inactivity watchdog, and any pre-warm process."""
|
||||
# Stop watchdog first so it cannot fire next_media after we've moved on.
|
||||
self._stop_inactivity_watchdog()
|
||||
self._kill_weblink_preload()
|
||||
|
||||
proc = getattr(self, '_weblink_proc', None)
|
||||
if proc is not None and proc.poll() is None:
|
||||
try:
|
||||
@@ -1581,6 +1814,14 @@ class SignagePlayer(Widget):
|
||||
Logger.warning(f"SignagePlayer: Error closing weblink browser: {e}")
|
||||
self._weblink_proc = None
|
||||
|
||||
# Raise the Kivy window back to the front — when Chromium ran in kiosk
|
||||
# mode it covered the Kivy window entirely; the window manager won't
|
||||
# automatically bring it back on all compositors/WMs.
|
||||
try:
|
||||
Window.raise_window()
|
||||
except Exception as exc:
|
||||
Logger.debug(f"SignagePlayer: raise_window failed (non-fatal): {exc}")
|
||||
|
||||
def _skip_to_next_media(self):
|
||||
"""Advance past a failed item WITHOUT recursing.
|
||||
|
||||
@@ -1629,6 +1870,71 @@ class SignagePlayer(Widget):
|
||||
Clock.unschedule(self.next_media)
|
||||
self.play_current_media()
|
||||
|
||||
def _prewarm_weblink(self, url):
|
||||
"""Launch Chromium off-screen to warm up the binary and page cache.
|
||||
|
||||
The window is placed far outside the visible area so the user never
|
||||
sees it. When play_weblink() fires for real it kills this hidden
|
||||
instance first, then relaunches in kiosk mode. Because the Chromium
|
||||
binary is already resident in RAM and the page is in the disk cache,
|
||||
the visible kiosk window appears almost immediately.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
from urllib.parse import urlparse
|
||||
|
||||
if not url:
|
||||
return
|
||||
scheme = urlparse(url).scheme.lower()
|
||||
if scheme not in ('http', 'https'):
|
||||
return
|
||||
|
||||
browser = shutil.which('chromium-browser') or shutil.which('chromium')
|
||||
if not browser:
|
||||
return
|
||||
|
||||
# Kill any stale preload first.
|
||||
self._kill_weblink_preload()
|
||||
|
||||
try:
|
||||
Logger.debug(f"SignagePlayer: Pre-warming weblink off-screen: {url}")
|
||||
self._weblink_preload_proc = subprocess.Popen([
|
||||
browser,
|
||||
'--app=' + url,
|
||||
# Place window completely outside the visible display area.
|
||||
'--window-position=-9999,-9999',
|
||||
'--window-size=1920,1080', # pre-render at full resolution
|
||||
'--noerrdialogs',
|
||||
'--disable-infobars',
|
||||
'--incognito',
|
||||
'--no-first-run',
|
||||
'--disable-session-crashed-bubble',
|
||||
'--check-for-update-interval=31536000',
|
||||
'--password-store=basic',
|
||||
'--use-mock-keychain',
|
||||
'--disable-sync',
|
||||
'--disable-background-networking',
|
||||
'--no-default-browser-check',
|
||||
])
|
||||
except Exception as exc:
|
||||
Logger.debug(f"SignagePlayer: Pre-warm launch failed (non-fatal): {exc}")
|
||||
self._weblink_preload_proc = None
|
||||
|
||||
def _kill_weblink_preload(self):
|
||||
"""Terminate the hidden pre-warm Chromium process if running."""
|
||||
proc = getattr(self, '_weblink_preload_proc', None)
|
||||
if proc is not None and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
Logger.debug("SignagePlayer: Killed weblink pre-warm process")
|
||||
except Exception as exc:
|
||||
Logger.debug(f"SignagePlayer: Error killing pre-warm process: {exc}")
|
||||
self._weblink_preload_proc = None
|
||||
|
||||
def preload_next_media(self):
|
||||
"""Preload the next media item asynchronously to improve transition smoothness
|
||||
|
||||
@@ -1668,6 +1974,11 @@ class SignagePlayer(Widget):
|
||||
Logger.debug(f"SignagePlayer: Preloading next image: {file_name}")
|
||||
Loader.image(media_path)
|
||||
|
||||
elif next_media_item.get('type') == 'weblink':
|
||||
# Pre-warm Chromium in a hidden off-screen window so the binary
|
||||
# and page content are in OS cache before the slot arrives.
|
||||
self._prewarm_weblink(next_media_item.get('url', ''))
|
||||
|
||||
except Exception as e:
|
||||
Logger.debug(f"SignagePlayer: Error preloading next media: {e}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user