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:
@@ -0,0 +1,245 @@
|
|||||||
|
# Web Link Playlist Items — Player Integration Guide
|
||||||
|
|
||||||
|
This document describes the changes required on the **Kiwy-Signage player**
|
||||||
|
(<https://gitea.moto-adv.com/ske087/Kiwy-Signage.git>) to support a new
|
||||||
|
playlist item type: **`weblink`** (display a live web page / URL instead of an
|
||||||
|
uploaded media file).
|
||||||
|
|
||||||
|
> The DigiServer (this repo, `digiserver-v2`) side will be updated to emit
|
||||||
|
> `weblink` items in the playlist API. The player does **not** yet support them.
|
||||||
|
> Use this guide to implement the player side later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Background — how items flow today
|
||||||
|
|
||||||
|
```
|
||||||
|
DigiServer API ──JSON──▶ player sync (get_playlists_v2.py) ──▶ playlist.json ──▶ main.py renders
|
||||||
|
/api/playlists downloads files to media/ by file extension
|
||||||
|
```
|
||||||
|
|
||||||
|
Each playlist item the server returns currently looks like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"file_name": "promo.jpg",
|
||||||
|
"type": "image",
|
||||||
|
"duration": 10,
|
||||||
|
"position": 1,
|
||||||
|
"url": "https://server/digiserver/static/uploads/promo.jpg",
|
||||||
|
"edit_on_player": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The player:
|
||||||
|
1. **Syncs** (`src/get_playlists_v2.py` → `download_media_files()`): downloads
|
||||||
|
`url` into the local `media/` directory, then rewrites each item keeping only
|
||||||
|
`file_name`, `url` (now a **local relative path**), `duration`,
|
||||||
|
`edit_on_player`. **Note: the `type` field is currently discarded here.**
|
||||||
|
2. **Renders** (`src/main.py` → `play_current_media()`): opens the local file
|
||||||
|
and chooses a Kivy widget purely by **file extension**
|
||||||
|
(`.mp4/.avi/...` → `Video`, `.jpg/.png/...` → `AsyncImage`). Unknown
|
||||||
|
extensions are skipped as "unsupported".
|
||||||
|
|
||||||
|
A web link breaks all three assumptions: there is no file to download, no
|
||||||
|
extension to switch on, and no widget that renders a web page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. New server contract (what DigiServer will send)
|
||||||
|
|
||||||
|
A web-link playlist item will look like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 91,
|
||||||
|
"file_name": "weblink-3f9c1a2b",
|
||||||
|
"type": "weblink",
|
||||||
|
"duration": 30,
|
||||||
|
"position": 4,
|
||||||
|
"url": "https://example.com/dashboard",
|
||||||
|
"edit_on_player": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key differences vs. a file item:
|
||||||
|
|
||||||
|
| Field | File item | Web-link item |
|
||||||
|
|--------------|-----------------------------------|----------------------------------------|
|
||||||
|
| `type` | `image` / `video` | **`weblink`** |
|
||||||
|
| `url` | Path to a file on the server | **The web page to display (the link)** |
|
||||||
|
| `file_name` | Real filename on disk | Synthetic id (`weblink-<uuid>`), **no file exists** |
|
||||||
|
|
||||||
|
The player must branch on `type == "weblink"` and treat `url` as the page to
|
||||||
|
open — **never** try to download it as a file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Required player changes
|
||||||
|
|
||||||
|
### 3.1 Sync step — `src/get_playlists_v2.py`
|
||||||
|
|
||||||
|
**Function:** `download_media_files(playlist, media_dir, ...)`
|
||||||
|
|
||||||
|
1. **Skip download for web links.** At the top of the per-item loop, detect
|
||||||
|
`media.get('type') == 'weblink'` and do **not** call `session.get()` / write
|
||||||
|
any file for it.
|
||||||
|
2. **Preserve `type` and the original `url`.** The `updated_media` dict that is
|
||||||
|
appended to `updated_playlist` currently drops `type` and rewrites `url` to a
|
||||||
|
local path. It must now carry `type` through, and for web links keep `url`
|
||||||
|
as the original web address (do not convert to a local relative path).
|
||||||
|
|
||||||
|
Suggested shape of the per-item logic:
|
||||||
|
|
||||||
|
```python
|
||||||
|
item_type = media.get('type', '')
|
||||||
|
|
||||||
|
if item_type == 'weblink':
|
||||||
|
# No file to download — pass the web link through unchanged.
|
||||||
|
updated_playlist.append({
|
||||||
|
'file_name': media.get('file_name', ''),
|
||||||
|
'type': 'weblink',
|
||||||
|
'url': media.get('url', ''), # the actual web page
|
||||||
|
'duration': media.get('duration', 10),
|
||||||
|
'edit_on_player': False,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ... existing download logic for file items ...
|
||||||
|
updated_playlist.append({
|
||||||
|
'file_name': file_name,
|
||||||
|
'type': item_type, # <-- now preserved
|
||||||
|
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
|
||||||
|
'duration': duration,
|
||||||
|
'edit_on_player': media.get('edit_on_player', False),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **`delete_unused_media()`** walks `media/` using `file_name`. Web links have
|
||||||
|
no file, so they simply won't match anything on disk — no change strictly
|
||||||
|
required, but make sure a missing local file for a `weblink` item does not
|
||||||
|
trigger a re-download or an error elsewhere.
|
||||||
|
|
||||||
|
### 3.2 Render step — `src/main.py`
|
||||||
|
|
||||||
|
**Function:** `play_current_media(self, force_reload=False)`
|
||||||
|
|
||||||
|
The current logic builds `media_path = os.path.join(self.media_dir, file_name)`
|
||||||
|
and then does `os.stat(media_path)` — which will fail for a web link (no file).
|
||||||
|
Add a **web-link branch before the file-existence check**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
media_item = self.playlist[self.current_index]
|
||||||
|
file_name = media_item.get('file_name', '')
|
||||||
|
duration = media_item.get('duration', 10)
|
||||||
|
|
||||||
|
# NEW: handle web links before any file/path handling
|
||||||
|
if media_item.get('type') == 'weblink':
|
||||||
|
self.play_weblink(media_item.get('url', ''), duration)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ... existing file existence check + extension branching ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add a new method `play_weblink(self, url, duration)`:
|
||||||
|
|
||||||
|
- Validate the scheme is `http`/`https` (reject anything else, e.g. `file://`).
|
||||||
|
- Open the page for `duration` seconds, then advance with `self.next_media()`.
|
||||||
|
- Wrap in `try/except`; on failure increment `self.consecutive_errors` and call
|
||||||
|
`self.next_media()`, matching the existing error-handling pattern.
|
||||||
|
- Make sure the previous widget (`self.current_widget`) is removed/stopped just
|
||||||
|
like the image/video paths do.
|
||||||
|
|
||||||
|
#### Rendering approach (pick one)
|
||||||
|
|
||||||
|
Kivy has **no production-grade embedded web view**, especially on Raspberry Pi.
|
||||||
|
Recommended options, in order of robustness:
|
||||||
|
|
||||||
|
1. **Chromium kiosk overlay (recommended).** Launch Chromium over the Kivy
|
||||||
|
window for the item's duration, then close it and return to Kivy:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import subprocess, shutil
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from kivy.clock import Clock
|
||||||
|
|
||||||
|
def play_weblink(self, url, duration):
|
||||||
|
scheme = urlparse(url).scheme.lower()
|
||||||
|
if scheme not in ('http', 'https'):
|
||||||
|
Logger.warning(f"SignagePlayer: Refusing non-http(s) weblink: {url}")
|
||||||
|
self.next_media()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
browser = shutil.which('chromium-browser') or shutil.which('chromium')
|
||||||
|
self._weblink_proc = subprocess.Popen([
|
||||||
|
browser,
|
||||||
|
'--kiosk', '--app=' + url,
|
||||||
|
'--noerrdialogs', '--disable-infobars',
|
||||||
|
'--incognito', '--no-first-run',
|
||||||
|
'--check-for-update-interval=31536000',
|
||||||
|
])
|
||||||
|
Clock.schedule_once(lambda dt: self._close_weblink_and_next(), duration)
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"SignagePlayer: Error opening weblink: {e}")
|
||||||
|
self.consecutive_errors += 1
|
||||||
|
self.next_media()
|
||||||
|
|
||||||
|
def _close_weblink_and_next(self):
|
||||||
|
proc = getattr(self, '_weblink_proc', None)
|
||||||
|
if proc and proc.poll() is None:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
proc.kill()
|
||||||
|
self._weblink_proc = None
|
||||||
|
self.next_media()
|
||||||
|
```
|
||||||
|
|
||||||
|
Requirements / notes:
|
||||||
|
- Install Chromium on the player image (`chromium-browser` on Raspberry Pi OS).
|
||||||
|
- Ensure Chromium gets window focus over Kivy and is fully killed before the
|
||||||
|
next item, including on pause/stop/restart paths and on app shutdown
|
||||||
|
(`on_stop`) so no stray browser window is left behind.
|
||||||
|
- On Wayland/X11 the player already sets `SDL_VIDEODRIVER`; verify Chromium
|
||||||
|
launches on the same display/session.
|
||||||
|
|
||||||
|
2. **Embedded web view widget** (`kivy_garden.webview`, WebKit/GTK, or WebView2).
|
||||||
|
Cleaner UX (stays inside the Kivy widget tree) but fragile and poorly
|
||||||
|
supported on Pi/Wayland — only pursue if option 1 is unacceptable.
|
||||||
|
|
||||||
|
3. **Server-side screenshot fallback (no player change).** If embedding a live
|
||||||
|
browser is not desirable, DigiServer can periodically screenshot the URL and
|
||||||
|
store it as a normal `image` item; the player then needs no changes. This
|
||||||
|
loses live/animated content. Documented here for completeness only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Checklist for the player update
|
||||||
|
|
||||||
|
- [ ] `get_playlists_v2.py`: skip download when `type == 'weblink'`.
|
||||||
|
- [ ] `get_playlists_v2.py`: preserve `type` in the rewritten playlist items
|
||||||
|
(fixes the current loss of `type`).
|
||||||
|
- [ ] `get_playlists_v2.py`: keep the original web `url` for weblink items.
|
||||||
|
- [ ] `main.py` `play_current_media()`: branch to `play_weblink()` **before** the
|
||||||
|
`os.stat()` file check.
|
||||||
|
- [ ] `main.py`: implement `play_weblink(url, duration)` (Chromium kiosk).
|
||||||
|
- [ ] `main.py`: validate scheme is `http`/`https`; reject others.
|
||||||
|
- [ ] Kill/cleanup the browser process on next item, pause, stop, restart, and
|
||||||
|
`on_stop`.
|
||||||
|
- [ ] Install Chromium on the player image / document it in the player README.
|
||||||
|
- [ ] Test: mixed playlist (image → video → weblink → image) cycles correctly
|
||||||
|
and respects per-item `duration`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Security notes
|
||||||
|
|
||||||
|
- Only allow `http`/`https` schemes on both server and player; never open
|
||||||
|
`file://`, `chrome://`, etc.
|
||||||
|
- The server validates and stores the URL when the operator adds it; the player
|
||||||
|
should still re-validate the scheme before launching the browser (defence in
|
||||||
|
depth).
|
||||||
|
- Consider running Chromium with `--incognito` (no persistent cookies/cache) as
|
||||||
|
shown above.
|
||||||
@@ -243,6 +243,20 @@ def download_media_files(playlist, media_dir, ssl_manager=None, server_url=None)
|
|||||||
file_name = media.get('file_name', '')
|
file_name = media.get('file_name', '')
|
||||||
file_url = media.get('url', '')
|
file_url = media.get('url', '')
|
||||||
duration = media.get('duration', 10)
|
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)
|
local_path = os.path.join(media_dir, file_name)
|
||||||
|
|
||||||
logger.info(f"📥 Preparing to download {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)
|
# (it might already exist or be available later)
|
||||||
updated_media = {
|
updated_media = {
|
||||||
'file_name': file_name,
|
'file_name': file_name,
|
||||||
|
'type': item_type, # Preserve media type (image/video/...)
|
||||||
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
|
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
|
||||||
'duration': duration,
|
'duration': duration,
|
||||||
'edit_on_player': media.get('edit_on_player', False) # Preserve edit_on_player flag
|
'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.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.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
|
||||||
@@ -1293,6 +1294,28 @@ 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
|
||||||
|
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
|
# Construct full path to media file
|
||||||
media_path = os.path.join(self.media_dir, file_name)
|
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: ❌ Media file not found: {media_path}")
|
||||||
Logger.error(f"SignagePlayer: Skipping to next media...")
|
Logger.error(f"SignagePlayer: Skipping to next media...")
|
||||||
self.consecutive_errors += 1
|
self.consecutive_errors += 1
|
||||||
self.next_media()
|
self._skip_to_next_media()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Remove status label if showing
|
# Remove status label if showing
|
||||||
self.ids.status_label.opacity = 0
|
self.ids.status_label.opacity = 0
|
||||||
|
|
||||||
# Remove previous media widget
|
# Remove previous media widget
|
||||||
if self.current_widget:
|
self._remove_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")
|
|
||||||
|
|
||||||
# Determine media type and create appropriate widget
|
# Determine media type and create appropriate widget
|
||||||
file_extension = os.path.splitext(file_name)[1].lower()
|
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: Supported: .mp4/.avi/.mkv/.mov/.webm/.jpg/.jpeg/.png/.bmp/.gif/.webp")
|
||||||
Logger.warning(f"SignagePlayer: Skipping to next media...")
|
Logger.warning(f"SignagePlayer: Skipping to next media...")
|
||||||
self.consecutive_errors += 1
|
self.consecutive_errors += 1
|
||||||
self.next_media()
|
self._skip_to_next_media()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Send feedback to server asynchronously (non-blocking)
|
# Send feedback to server asynchronously (non-blocking)
|
||||||
@@ -1372,7 +1383,7 @@ class SignagePlayer(Widget):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.show_error(f"Error playing media: {e}")
|
self.show_error(f"Error playing media: {e}")
|
||||||
self.next_media()
|
self._skip_to_next_media()
|
||||||
|
|
||||||
def play_video(self, video_path, duration):
|
def play_video(self, video_path, duration):
|
||||||
"""Play a video file using Kivy's Video widget with optimizations"""
|
"""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):
|
if not os.path.exists(video_path):
|
||||||
Logger.error(f"SignagePlayer: ❌ Video file not found: {video_path}")
|
Logger.error(f"SignagePlayer: ❌ Video file not found: {video_path}")
|
||||||
self.consecutive_errors += 1
|
self.consecutive_errors += 1
|
||||||
self.next_media()
|
self._skip_to_next_media()
|
||||||
return
|
return
|
||||||
|
|
||||||
Logger.debug(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s")
|
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:
|
except Exception as e:
|
||||||
Logger.error(f"SignagePlayer: Error playing video {video_path}: {e}")
|
Logger.error(f"SignagePlayer: Error playing video {video_path}: {e}")
|
||||||
self.consecutive_errors += 1
|
self.consecutive_errors += 1
|
||||||
if self.consecutive_errors < self.max_consecutive_errors:
|
self._skip_to_next_media()
|
||||||
self.next_media()
|
|
||||||
|
|
||||||
def _on_video_eos(self, instance):
|
def _on_video_eos(self, instance):
|
||||||
"""Callback when video reaches end of stream"""
|
"""Callback when video reaches end of stream"""
|
||||||
@@ -1476,8 +1486,120 @@ class SignagePlayer(Widget):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
Logger.error(f"SignagePlayer: Error playing image {image_path}: {e}")
|
Logger.error(f"SignagePlayer: Error playing image {image_path}: {e}")
|
||||||
self.consecutive_errors += 1
|
self.consecutive_errors += 1
|
||||||
if self.consecutive_errors < self.max_consecutive_errors:
|
self._skip_to_next_media()
|
||||||
self.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):
|
def next_media(self, dt=None):
|
||||||
"""Move to next media item"""
|
"""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_normal = self.resources_path + '/play.png'
|
||||||
self.ids.play_pause_btn.background_down = self.resources_path + '/play.png'
|
self.ids.play_pause_btn.background_down = self.resources_path + '/play.png'
|
||||||
Clock.unschedule(self.next_media)
|
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
|
# Cancel any existing auto-resume
|
||||||
if self.auto_resume_event:
|
if self.auto_resume_event:
|
||||||
@@ -1936,6 +2060,14 @@ class SignagePlayerApp(App):
|
|||||||
def on_stop(self):
|
def on_stop(self):
|
||||||
Logger.info("SignagePlayerApp: Application stopped")
|
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
|
# Stop network monitoring
|
||||||
if hasattr(self.root, 'network_monitor') and self.root.network_monitor:
|
if hasattr(self.root, 'network_monitor') and self.root.network_monitor:
|
||||||
self.root.network_monitor.stop_monitoring()
|
self.root.network_monitor.stop_monitoring()
|
||||||
|
|||||||
Reference in New Issue
Block a user