Compare commits

...

2 Commits

Author SHA1 Message Date
Kiwy Signage Player f30f97683b 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
2026-06-29 20:54:44 +03:00
Kiwy Signage Player 609d9169d2 updated player for deploy from server 2026-06-29 19:59:49 +03:00
8 changed files with 377 additions and 50 deletions
+1 -1
View File
@@ -1 +1 @@
1768682062.8464386 1782752485.750587
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"server_ip": "192.168.0.121", "server_ip": "192.168.0.159",
"port": "443", "port": "8080",
"screen_name": "rpi-tvcanba1", "screen_name": "rpi-Receptie",
"quickconnect_key": "8887779", "quickconnect_key": "8887779",
"orientation": "Landscape", "orientation": "Landscape",
"touch": "True", "touch": "True",
"max_resolution": "1920x1080", "max_resolution": "1920x1080",
"edit_feature_enabled": true, "edit_feature_enabled": true,
"use_https": true, "use_https": false,
"verify_ssl": false "verify_ssl": false
} }
-10
View File
@@ -1,10 +0,0 @@
{
"hostname": "rpi-tvcanba1",
"auth_code": "LhfERILw4cFxejhbIUuQ72QddisRgHMAm7kUSty64LA",
"player_id": 1,
"player_name": "TVacasa",
"playlist_id": 1,
"orientation": "Landscape",
"authenticated": true,
"server_url": "https://192.168.0.121:443"
}
+12 -16
View File
@@ -1,27 +1,23 @@
{ {
"count": 3, "count": 2,
"player_id": 1, "player_id": 1,
"player_name": "TVacasa", "player_name": "Receptie TV",
"playlist": [ "playlist": [
{ {
"file_name": "2026efvev-1428673176.jpg", "file_name": "sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
"url": "media/2026efvev-1428673176.jpg", "type": "image",
"duration": 50, "url": "media/sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
"edit_on_player": true "duration": 25,
"edit_on_player": false
}, },
{ {
"file_name": "4k1.jpg", "file_name": "robert-lukeman-zNN6ubHmruI-unsplash.jpg",
"url": "media/4k1.jpg", "type": "image",
"duration": 30, "url": "media/robert-lukeman-zNN6ubHmruI-unsplash.jpg",
"edit_on_player": true "duration": 20,
},
{
"file_name": "1416529-hd_1920_1080_30fps.mp4",
"url": "media/1416529-hd_1920_1080_30fps.mp4",
"duration": 13,
"edit_on_player": false "edit_on_player": false
} }
], ],
"playlist_id": 1, "playlist_id": 1,
"playlist_version": 34 "playlist_version": 15
} }
+1 -1
View File
@@ -74,7 +74,7 @@ def ensure_authenticated(config):
# Use HTTPS for IP addresses # Use HTTPS for IP addresses
server_url = f'https://{server_ip}:{port}' if port else f'https://{server_ip}' server_url = f'https://{server_ip}:{port}' if port else f'https://{server_ip}'
else: else:
server_url = f'http://{server_ip}:{port}' server_url = f'http://{server_ip}:{port}' if port else f'http://{server_ip}'
else: else:
# For domain names, use HTTPS by default # For domain names, use HTTPS by default
if use_https: if use_https:
+329 -12
View File
@@ -3,6 +3,8 @@ Kivy Signage Player - Main Application
Displays content from DigiServer playlists using Kivy framework Displays content from DigiServer playlists using Kivy framework
""" """
PLAYER_VERSION = "1.2.0"
import os import os
import json import json
import platform import platform
@@ -640,6 +642,7 @@ class SettingsPopup(Popup):
# Populate current values # Populate current values
self.ids.server_input.text = self.player.config.get('server_ip', 'localhost') self.ids.server_input.text = self.player.config.get('server_ip', 'localhost')
self.ids.port_input.text = str(self.player.config.get('port', ''))
self.ids.screen_input.text = self.player.config.get('screen_name', 'kivy-player') self.ids.screen_input.text = self.player.config.get('screen_name', 'kivy-player')
self.ids.quickconnect_input.text = self.player.config.get('quickconnect_key', '1234567') self.ids.quickconnect_input.text = self.player.config.get('quickconnect_key', '1234567')
self.ids.orientation_input.text = self.player.config.get('orientation', 'Landscape') self.ids.orientation_input.text = self.player.config.get('orientation', 'Landscape')
@@ -723,7 +726,7 @@ class SettingsPopup(Popup):
server_ip = self.ids.server_input.text.strip() server_ip = self.ids.server_input.text.strip()
screen_name = self.ids.screen_input.text.strip() screen_name = self.ids.screen_input.text.strip()
quickconnect = self.ids.quickconnect_input.text.strip() quickconnect = self.ids.quickconnect_input.text.strip()
port = self.player.config.get('port', '443') port = self.ids.port_input.text.strip() or self.player.config.get('port', '')
use_https = self.player.config.get('use_https', True) use_https = self.player.config.get('use_https', True)
verify_ssl = self.player.config.get('verify_ssl', True) verify_ssl = self.player.config.get('verify_ssl', True)
@@ -741,7 +744,11 @@ class SettingsPopup(Popup):
server_url = f"{server_ip}:{port}" server_url = f"{server_ip}:{port}"
else: else:
protocol = "https" if use_https else "http" protocol = "https" if use_https else "http"
server_url = f"{protocol}://{server_ip}:{port}" if ':' in server_ip:
# server_ip already contains a port (e.g. 192.168.0.230:8080)
server_url = f"{protocol}://{server_ip}"
else:
server_url = f"{protocol}://{server_ip}:{port}" if port else f"{protocol}://{server_ip}"
Logger.info(f"SettingsPopup: Testing connection to {server_url} (HTTPS: {use_https}, Verify SSL: {verify_ssl})") Logger.info(f"SettingsPopup: Testing connection to {server_url} (HTTPS: {use_https}, Verify SSL: {verify_ssl})")
@@ -875,6 +882,7 @@ class SettingsPopup(Popup):
"""Save configuration and close popup""" """Save configuration and close popup"""
# Update config # Update config
self.player.config['server_ip'] = self.ids.server_input.text self.player.config['server_ip'] = self.ids.server_input.text
self.player.config['port'] = self.ids.port_input.text.strip()
self.player.config['screen_name'] = self.ids.screen_input.text self.player.config['screen_name'] = self.ids.screen_input.text
self.player.config['quickconnect_key'] = self.ids.quickconnect_input.text self.player.config['quickconnect_key'] = self.ids.quickconnect_input.text
self.player.config['orientation'] = self.ids.orientation_input.text self.player.config['orientation'] = self.ids.orientation_input.text
@@ -912,7 +920,10 @@ class SignagePlayer(Widget):
self.playlist = [] self.playlist = []
self.current_index = 0 self.current_index = 0
self.current_widget = None 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_playing = False
self.is_paused = False self.is_paused = False
self.auto_resume_event = None # Track scheduled auto-resume self.auto_resume_event = None # Track scheduled auto-resume
@@ -1271,11 +1282,13 @@ class SignagePlayer(Widget):
self.current_index = 0 self.current_index = 0
self.play_current_media() 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 """Play the current media item
Args: Args:
force_reload: If True, clears image cache before loading (for edited images) 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) # Don't play if paused (unless we're explicitly resuming)
if self.is_paused: if self.is_paused:
@@ -1294,14 +1307,72 @@ class SignagePlayer(Widget):
Logger.info(f"SignagePlayer: Playing item {self.current_index + 1}/{len(self.playlist)}: {file_name} ({duration}s)") 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 # ── Weblink → media transition (Wayland-safe) ──────────────────
self._kill_weblink_process() # 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) # Handle web links before any file/path handling (no local file exists)
if media_item.get('type') == 'weblink': if media_item.get('type') == 'weblink':
Logger.debug("SignagePlayer: Media type: WEBLINK") Logger.debug("SignagePlayer: Media type: WEBLINK")
self.ids.status_label.opacity = 0 self.ids.status_label.opacity = 0
self._remove_current_widget() 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) started = self.play_weblink(media_item.get('url', ''), duration)
if started: if started:
self.consecutive_errors = 0 self.consecutive_errors = 0
@@ -1532,10 +1603,19 @@ class SignagePlayer(Widget):
return False return False
try: 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([ self._weblink_proc = subprocess.Popen([
browser, 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, '--app=' + url,
'--noerrdialogs', '--noerrdialogs',
'--disable-infobars', '--disable-infobars',
@@ -1543,25 +1623,184 @@ class SignagePlayer(Widget):
'--no-first-run', '--no-first-run',
'--disable-session-crashed-bubble', '--disable-session-crashed-bubble',
'--check-for-update-interval=31536000', '--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 # Unschedule any previous fixed timer the watchdog thread takes
# at the start of the next play_current_media() via _kill_weblink_process(). # over and only advances after 'duration' seconds of NO touch activity.
Clock.unschedule(self.next_media) 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. # Preload the next image so the transition after the weblink is smooth.
self.preload_next_media() self.preload_next_media()
return True return True
except Exception as e: except Exception as e:
Logger.error(f"SignagePlayer: Error opening weblink {url}: {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.consecutive_errors += 1
self._weblink_proc = None self._weblink_proc = None
self._skip_to_next_media() self._skip_to_next_media()
return False 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): 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) proc = getattr(self, '_weblink_proc', None)
if proc is not None and proc.poll() is None: if proc is not None and proc.poll() is None:
try: try:
@@ -1575,6 +1814,14 @@ class SignagePlayer(Widget):
Logger.warning(f"SignagePlayer: Error closing weblink browser: {e}") Logger.warning(f"SignagePlayer: Error closing weblink browser: {e}")
self._weblink_proc = None 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): def _skip_to_next_media(self):
"""Advance past a failed item WITHOUT recursing. """Advance past a failed item WITHOUT recursing.
@@ -1623,6 +1870,71 @@ class SignagePlayer(Widget):
Clock.unschedule(self.next_media) Clock.unschedule(self.next_media)
self.play_current_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): def preload_next_media(self):
"""Preload the next media item asynchronously to improve transition smoothness """Preload the next media item asynchronously to improve transition smoothness
@@ -1662,6 +1974,11 @@ class SignagePlayer(Widget):
Logger.debug(f"SignagePlayer: Preloading next image: {file_name}") Logger.debug(f"SignagePlayer: Preloading next image: {file_name}")
Loader.image(media_path) 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: except Exception as e:
Logger.debug(f"SignagePlayer: Error preloading next media: {e}") Logger.debug(f"SignagePlayer: Error preloading next media: {e}")
+4 -4
View File
@@ -1,10 +1,10 @@
{ {
"hostname": "rpi-tvcanba1", "hostname": "rpi-Receptie",
"auth_code": "LhfERILw4cFxejhbIUuQ72QddisRgHMAm7kUSty64LA", "auth_code": "BrJrGzX_IT9oP_Lfke8Qgo4sDjMc49T1EhSnWmKKpDM",
"player_id": 1, "player_id": 1,
"player_name": "TVacasa", "player_name": "Receptie TV",
"playlist_id": 1, "playlist_id": 1,
"orientation": "Landscape", "orientation": "Landscape",
"authenticated": true, "authenticated": true,
"server_url": "https://192.168.0.121:443" "server_url": "http://192.168.0.159:8080"
} }
+24
View File
@@ -382,6 +382,30 @@
write_tab: False write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Server port
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(40)
spacing: dp(10)
Label:
text: 'Port:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: port_input
size_hint_x: 0.7
multiline: False
font_size: sp(14)
hint_text: '80 or 8080 (leave empty for default)'
input_filter: 'int'
write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Screen name # Screen name
BoxLayout: BoxLayout:
orientation: 'horizontal' orientation: 'horizontal'