Add weblink playlist support and fix offline playback recursion

- get_playlists_v2: pass through weblink items without download, preserve type
- main.py: render weblink items fullscreen via Chromium kiosk overlay with cleanup on next/pause/stop
- main.py: fix unbounded recursion when playlist media is missing/invalid by scheduling retries via Clock (keeps standard playlist cycling offline)
- docs: add PLAYER_WEBLINK_INTEGRATION.md
This commit is contained in:
Kiwy Signage Player
2026-06-28 13:46:29 +03:00
parent 2c31589787
commit abbd51a787
3 changed files with 413 additions and 21 deletions
+15
View File
@@ -243,6 +243,20 @@ def download_media_files(playlist, media_dir, ssl_manager=None, server_url=None)
file_name = media.get('file_name', '')
file_url = media.get('url', '')
duration = media.get('duration', 10)
item_type = media.get('type', '')
# Web-link items have no file to download — pass the link through unchanged.
if item_type == 'weblink':
logger.info(f"🔗 Web link item (no download): {file_url}")
updated_playlist.append({
'file_name': file_name,
'type': 'weblink',
'url': file_url, # keep the original web address (not a local path)
'duration': duration,
'edit_on_player': False,
})
continue
local_path = os.path.join(media_dir, file_name)
logger.info(f"📥 Preparing to download {file_name}...")
@@ -307,6 +321,7 @@ def download_media_files(playlist, media_dir, ssl_manager=None, server_url=None)
# (it might already exist or be available later)
updated_media = {
'file_name': file_name,
'type': item_type, # Preserve media type (image/video/...)
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
'duration': duration,
'edit_on_player': media.get('edit_on_player', False) # Preserve edit_on_player flag
+153 -21
View File
@@ -912,6 +912,7 @@ 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.is_playing = False
self.is_paused = False
self.auto_resume_event = None # Track scheduled auto-resume
@@ -1293,6 +1294,28 @@ 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()
# 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()
started = self.play_weblink(media_item.get('url', ''), duration)
if started:
self.consecutive_errors = 0
if self.config:
asyncio.ensure_future(
self.async_send_feedback(
send_playing_status_feedback,
self.config,
self.playlist_version,
file_name
)
)
return
# Construct full path to media file
media_path = os.path.join(self.media_dir, file_name)
@@ -1304,26 +1327,14 @@ class SignagePlayer(Widget):
Logger.error(f"SignagePlayer: ❌ Media file not found: {media_path}")
Logger.error(f"SignagePlayer: Skipping to next media...")
self.consecutive_errors += 1
self.next_media()
self._skip_to_next_media()
return
# Remove status label if showing
self.ids.status_label.opacity = 0
# Remove previous media widget
if self.current_widget:
# Properly stop video if it's playing to prevent resource leaks
if isinstance(self.current_widget, Video):
try:
Logger.debug(f"SignagePlayer: Stopping previous video widget...")
self.current_widget.state = 'stop'
self.current_widget.unload()
except Exception as e:
Logger.warning(f"SignagePlayer: Error stopping video: {e}")
self.ids.content_area.remove_widget(self.current_widget)
self.current_widget = None
Logger.debug(f"SignagePlayer: Previous widget removed")
self._remove_current_widget()
# Determine media type and create appropriate widget
file_extension = os.path.splitext(file_name)[1].lower()
@@ -1341,7 +1352,7 @@ class SignagePlayer(Widget):
Logger.warning(f"SignagePlayer: Supported: .mp4/.avi/.mkv/.mov/.webm/.jpg/.jpeg/.png/.bmp/.gif/.webp")
Logger.warning(f"SignagePlayer: Skipping to next media...")
self.consecutive_errors += 1
self.next_media()
self._skip_to_next_media()
return
# Send feedback to server asynchronously (non-blocking)
@@ -1372,7 +1383,7 @@ class SignagePlayer(Widget):
return
self.show_error(f"Error playing media: {e}")
self.next_media()
self._skip_to_next_media()
def play_video(self, video_path, duration):
"""Play a video file using Kivy's Video widget with optimizations"""
@@ -1381,7 +1392,7 @@ class SignagePlayer(Widget):
if not os.path.exists(video_path):
Logger.error(f"SignagePlayer: ❌ Video file not found: {video_path}")
self.consecutive_errors += 1
self.next_media()
self._skip_to_next_media()
return
Logger.debug(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s")
@@ -1423,8 +1434,7 @@ class SignagePlayer(Widget):
except Exception as e:
Logger.error(f"SignagePlayer: Error playing video {video_path}: {e}")
self.consecutive_errors += 1
if self.consecutive_errors < self.max_consecutive_errors:
self.next_media()
self._skip_to_next_media()
def _on_video_eos(self, instance):
"""Callback when video reaches end of stream"""
@@ -1476,8 +1486,120 @@ class SignagePlayer(Widget):
except Exception as e:
Logger.error(f"SignagePlayer: Error playing image {image_path}: {e}")
self.consecutive_errors += 1
if self.consecutive_errors < self.max_consecutive_errors:
self.next_media()
self._skip_to_next_media()
def _remove_current_widget(self):
"""Stop and remove the current Kivy media widget if one is present."""
if self.current_widget:
# Properly stop video if it's playing to prevent resource leaks
if isinstance(self.current_widget, Video):
try:
Logger.debug("SignagePlayer: Stopping previous video widget...")
self.current_widget.state = 'stop'
self.current_widget.unload()
except Exception as e:
Logger.warning(f"SignagePlayer: Error stopping video: {e}")
self.ids.content_area.remove_widget(self.current_widget)
self.current_widget = None
Logger.debug("SignagePlayer: Previous widget removed")
def play_weblink(self, url, duration):
"""Display a live web page fullscreen using a Chromium kiosk overlay.
Kivy has no production-grade embedded web view on Raspberry Pi, so we
launch Chromium in kiosk mode over the Kivy window for the item's
duration, then close it and advance to the next item.
Returns True if the browser was launched, False otherwise.
"""
import shutil
import subprocess
from urllib.parse import urlparse
# Defence in depth: only ever open http/https links.
scheme = urlparse(url).scheme.lower()
if scheme not in ('http', 'https'):
Logger.warning(f"SignagePlayer: Refusing non-http(s) weblink: {url}")
self.consecutive_errors += 1
self._skip_to_next_media()
return False
browser = shutil.which('chromium-browser') or shutil.which('chromium')
if not browser:
Logger.error("SignagePlayer: Chromium not installed; cannot display weblink")
self.consecutive_errors += 1
self._skip_to_next_media()
return False
try:
Logger.info(f"SignagePlayer: Opening weblink in kiosk browser for {duration}s: {url}")
self._weblink_proc = subprocess.Popen([
browser,
'--kiosk',
'--app=' + url,
'--noerrdialogs',
'--disable-infobars',
'--incognito',
'--no-first-run',
'--disable-session-crashed-bubble',
'--check-for-update-interval=31536000',
])
# Advance after the configured duration. The kiosk browser is closed
# at the start of the next play_current_media() via _kill_weblink_process().
Clock.unschedule(self.next_media)
Clock.schedule_once(self.next_media, 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}")
self.consecutive_errors += 1
self._weblink_proc = None
self._skip_to_next_media()
return False
def _kill_weblink_process(self):
"""Terminate the kiosk browser process if one is running."""
proc = getattr(self, '_weblink_proc', None)
if proc is not None and proc.poll() is None:
try:
proc.terminate()
try:
proc.wait(timeout=5)
except Exception:
proc.kill()
Logger.debug("SignagePlayer: Closed weblink kiosk browser")
except Exception as e:
Logger.warning(f"SignagePlayer: Error closing weblink browser: {e}")
self._weblink_proc = None
def _skip_to_next_media(self):
"""Advance past a failed item WITHOUT recursing.
Error paths used to call next_media() synchronously, which re-entered
play_current_media() immediately. With a playlist whose files are all
missing/invalid (e.g. offline before the first media sync) this recursed
until Python's stack limit ('maximum recursion depth exceeded') and
crashed playback. Scheduling via the Kivy Clock unwinds the stack
between attempts and throttles retries so the standard playlist keeps
cycling and automatically resumes once content is available.
"""
Clock.unschedule(self.next_media)
if self.consecutive_errors >= self.max_consecutive_errors:
msg = "No playable media yet - waiting for content to sync..."
Logger.warning(f"SignagePlayer: {msg} ({self.consecutive_errors} errors)")
try:
self.ids.status_label.text = msg
self.ids.status_label.opacity = 1
except Exception:
pass
# Reset and retry slowly instead of stopping forever
self.consecutive_errors = 0
Clock.schedule_once(self.next_media, 30)
else:
Clock.schedule_once(self.next_media, 1)
def next_media(self, dt=None):
"""Move to next media item"""
@@ -1553,6 +1675,8 @@ class SignagePlayer(Widget):
self.ids.play_pause_btn.background_normal = self.resources_path + '/play.png'
self.ids.play_pause_btn.background_down = self.resources_path + '/play.png'
Clock.unschedule(self.next_media)
# Close any kiosk browser so the player controls are visible while paused
self._kill_weblink_process()
# Cancel any existing auto-resume
if self.auto_resume_event:
@@ -1936,6 +2060,14 @@ class SignagePlayerApp(App):
def on_stop(self):
Logger.info("SignagePlayerApp: Application stopped")
# Close any kiosk browser opened for a weblink item
try:
if self.root and hasattr(self.root, '_kill_weblink_process'):
self.root._kill_weblink_process()
Logger.info("SignagePlayerApp: Weblink browser closed")
except Exception as e:
Logger.debug(f"SignagePlayerApp: Error closing weblink browser: {e}")
# Stop network monitoring
if hasattr(self.root, 'network_monitor') and self.root.network_monitor:
self.root.network_monitor.stop_monitoring()