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:
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user