3ac7f836c4
Replaces the Windows port with a Raspberry Pi / Linux implementation on Raspberry Pi OS "Trixie" (Debian 13, aarch64, Wayland/labwc). The Windows code is removed here but preserved on the Windows-Player branch. Entry point ----------- linux/run_linux.py replaces windows/run_win.py. src/main.py stays platform-neutral; all Pi-specific behaviour is injected from linux/. Five bugs that prevented the port (all measured on real hardware) ---------------------------------------------------------------- 1. Kivy's PyPI wheel bundles an SDL2 built WITHOUT the wayland driver, so no window could be created (Trixie has no X server). linux/fix_kivy_sdl2.sh symlinks the system SDL2 over the bundled filename. 2. SDL2 requires WAYLAND_DISPLAY to be *set* - the socket alone is not enough, unlike wlopm. This broke every systemd/cron/autostart launch. linux_display.ensure_session_environment() detects and exports it. 3. Kivy's Clock resolves callbacks via func.__name__; a patch assigned under a different name crashed the player ~20s after a successful start. 4. The inherited signal_screen_activity() shelled out to tvservice, xdotool and ydotool - none exist on Trixie - and mis-escaped 'wlopm --on \*', so the display blanked after 10 minutes. 5. The launchers ran src/main.py directly, bypassing every platform patch and resolving the data directory one level too high. Web links --------- - --ozone-platform-hint=auto does NOT fall back to Wayland on Chromium 152; it aborts. The platform is now chosen explicitly. - The keyring password prompt is suppressed via the ENVIRONMENT, not the flags: launch_env() strips DBUS_SESSION_BUS_ADDRESS for the child so Chromium cannot reach gnome-keyring-daemon. - Teardown kills the whole process group (needs start_new_session=True); previously it silently fell back to terminate() and orphaned children. Video normalisation ------------------- A 4K video cannot play on a Pi 4: ffpyplayer decodes in software, measured at 0.90x realtime (1080p is 3.03x). Oversized media is downscaled to 1920x1080 at sync time using the hardware h264_v4l2m2m encoder (~31s for an 18s clip), triggered by resolution only so already-playable files are untouched. src/media_state.py owns the shared on-disk contract: a .kiwy-converting marker makes the player skip the item while it is being rebuilt, then the converted file is played instead. If nothing is playable at all (a single-item playlist whose only video is converting), the player loops the intro video rather than leaving a blank screen. Also fixed ---------- - network_monitor: replaced netsh/ifconfig/dhclient with nmcli (Trixie uses NetworkManager; ifconfig and dhclient are not even installed). - Removed the Windows-only focus keeper/guardian from main.py. - main.py: duplicate SDL_AUDIODRIVER setdefault (a silent no-op); Settings "Test connection" now uses tempfile.gettempdir(). - config/app_config.json: credentials blanked so a fresh clone runs the first-run setup flow. Verification ------------ linux/test_media_state.py 18/18, test_linux_patches.py 21/21, test_linux_browser_flags.py 27/27. Verified live against a real DigiServer: image -> weblink -> image -> video with correct durations, zero leaked Chromium processes, and no throttling over a 10 minute monitored run.
385 lines
16 KiB
Markdown
385 lines
16 KiB
Markdown
# Web Link Playlist Items — Player Integration Guide
|
||
|
||
This document describes how the **Kiwy-Signage player**
|
||
(<https://gitea.moto-adv.com/ske087/Kiwy-Signage.git>) supports the **`weblink`**
|
||
playlist item type (display a live web page / URL instead of an uploaded media
|
||
file).
|
||
|
||
> **Status: implemented.** The player supports `weblink` items on
|
||
> Raspberry Pi / Linux via a Chromium kiosk subprocess.
|
||
> Sections 1–4 describe the original design plan; section 6 documents the
|
||
> shipped architecture and the interaction model.
|
||
|
||
---
|
||
|
||
## 1. Background — how items flow
|
||
|
||
```
|
||
DigiServer API ──JSON──▶ player sync (get_playlists_v2.py) ──▶ playlist.json ──▶ main.py renders
|
||
/api/playlists downloads files to media/ by item type
|
||
```
|
||
|
||
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 a
|
||
platform WebView) — renders inside the Kivy window.
|
||
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.
|
||
|
||
---
|
||
|
||
## 6. Shipped architecture (`src/weblink_session.py`)
|
||
|
||
The player-side implementation lives in **one** module, so launch, verification,
|
||
timing and teardown have a single owner instead of being duplicated per
|
||
platform:
|
||
|
||
| Piece | Responsibility |
|
||
|--------------------------|----------------|
|
||
| `WeblinkSession` | Owns one weblink item: validate → launch → verify → watch → teardown. Generation-tokened so stale callbacks are ignored, and `atexit`-safe. |
|
||
| `WeblinkAdapter` | The only platform-specific part: launch / wait for the window / is it alive / tear it down / pre-warm. `extra_launch_args()` lets a subclass add browser flags without copying `launch()`. |
|
||
| `ChromiumSubprocessAdapter` | Default engine (Raspberry Pi `chromium`). |
|
||
| `InteractionWatcher` | Decides when the item is finished (see the interaction model below). |
|
||
| `WebInputSources` | Reads `/dev/input/event*` to detect viewer interaction. |
|
||
| `linux_browser.py` | **Raspberry Pi / Linux**: `LinuxChromiumAdapter` — Chromium kiosk on Wayland (labwc). |
|
||
|
||
Platform entry points inject their engine through
|
||
`SignagePlayer.weblink_adapter_factory`:
|
||
|
||
* **Raspberry Pi / Linux** (`linux/run_linux.py`) — injects
|
||
`LinuxChromiumAdapter`, which adds the flags Trixie needs:
|
||
|
||
| Flag | Why |
|
||
|------|-----|
|
||
| `--kiosk` | What actually makes labwc give the window exclusive fullscreen. Windows needed `--start-maximized` instead; that is not sufficient here. |
|
||
| `--user-data-dir=<.kiosk-profile>` | **Mandatory.** Without it Chromium hands the URL to an already-running instance, the process we launched exits in ~2 s and the item is skipped as a failed launch. Also guarantees we never touch the operator's own browser profile. |
|
||
| `--ozone-platform-hint=auto` | Lets Chromium pick Wayland when available and fall back to X11/XWayland. |
|
||
| `--autoplay-policy=no-user-gesture-required` | Signage pages play media without a click. |
|
||
|
||
Teardown kills the **whole process group** (`os.killpg`). `proc.terminate()`
|
||
only reaps the parent, leaving Chromium's GPU/zygote/renderer children behind;
|
||
across a 24/7 playlist those accumulate until the Pi runs out of memory.
|
||
|
||
`WeblinkAdapter.extra_launch_args()` is the hook subclasses use to add flags
|
||
without duplicating `launch()` — the Linux adapter uses it for
|
||
`--user-data-dir` + `--kiosk`.
|
||
|
||
> **Do not give the factory a class-level `None` default combined with an
|
||
> unconditional instance assignment.** `SignagePlayer.__init__` originally set
|
||
> `self.weblink_adapter_factory = None`, which shadowed the class attribute the
|
||
> platform entry point installs — so the platform adapters were silently ignored
|
||
> and every weblink fell back to the generic adapter and failed. It now only sets
|
||
> the instance attribute when the class attribute is absent.
|
||
|
||
### 6.2 Linux: Chromium kiosk on Wayland
|
||
|
||
Web links on Raspberry Pi OS Trixie use a single dedicated `chromium` process
|
||
launched in kiosk mode with its own profile directory:
|
||
|
||
* `--kiosk` gives an exclusive-fullscreen window under the **labwc** compositor;
|
||
* `--user-data-dir=<data>/.kiosk-profile` guarantees a *fresh, trackable* browser
|
||
instead of a hand-off to a running instance;
|
||
* `--ozone-platform-hint=auto` selects Wayland natively and falls back to X11.
|
||
|
||
Because a separate process cannot be introspected portably, visibility is
|
||
verified by combining "the process survived the health grace period" with a
|
||
best-effort check that the PID actually owns a Wayland/X11 socket. A launch that
|
||
dies sooner than `min_healthy_alive` is treated as a **failed launch** (hand-off,
|
||
missing binary, instant crash) rather than a finished item, so the playlist
|
||
never skips a weblink silently.
|
||
|
||
Stale `SingletonLock`/`SingletonSocket` files left by a crashed Chromium are
|
||
cleared before each launch: our profile is private to the player, so removing
|
||
the lock is always safe and prevents Chromium refusing to start.
|
||
|
||
### 6.1 Interaction model — web links are not passive media
|
||
|
||
`duration` on a weblink is **not** a hard cut-off. The player advances only when
|
||
**both** conditions are true:
|
||
|
||
1. the configured `duration` has elapsed; **and**
|
||
2. the viewer has not interacted with the page for `interaction_postpone`
|
||
seconds (default **10 s**), measured from the **most recent** interaction.
|
||
|
||
Consequences:
|
||
|
||
- A viewer who taps, scrolls or navigates the page during the final seconds of
|
||
the slot **keeps the page on screen** — the advance is pushed 10 s past that
|
||
touch, and every further touch pushes it again. The link is never pulled out
|
||
from under someone who is using it.
|
||
- An untouched page still advances on schedule, exactly like a media item.
|
||
- A multi-event burst (a drag, a page transition) counts as **one** interaction
|
||
but the countdown is measured from the **last** event of that burst, so an
|
||
item can never be cut off mid-gesture.
|
||
- `max_dwell` (duration × `max_dwell_factor`, at least `min_max_dwell`) is an
|
||
absolute backstop so a wedged browser or a jammed touchscreen cannot park the
|
||
playlist forever.
|
||
|
||
**Pause/play does not apply to web links.** A web link is an interactive
|
||
surface, so `toggle_pause()` is a no-op while one is on screen — the interaction
|
||
watcher owns its lifecycle. The pause button continues to work normally for
|
||
images and videos.
|
||
|
||
### 6.2 Verified start-up
|
||
|
||
Launching a browser is not the same as displaying a page. The session therefore
|
||
does **not** report success immediately after spawning the process (that used to
|
||
reset the error counter and leave a black screen for the whole duration). The
|
||
watcher thread — never the Kivy main thread — waits for the browser window to
|
||
appear, and if it never does the item is reported as failed and skipped.
|
||
|
||
### 6.3 Configuration
|
||
|
||
All timings are tunable in `config/app_config.json` under `weblink`:
|
||
|
||
```json
|
||
"weblink": {
|
||
"engine": "auto",
|
||
"interaction_postpone": 10,
|
||
"interaction_debounce": 0.5,
|
||
"interaction_grace": 5.0,
|
||
"max_dwell_factor": 6.0,
|
||
"min_max_dwell": 300,
|
||
"launch_timeout": 15,
|
||
"prewarm": true
|
||
}
|
||
```
|
||
|
||
| Key | Meaning |
|
||
|-----|---------|
|
||
| `engine` | Preferred engine (`auto`, `cef`, `subprocess`). |
|
||
| `interaction_postpone` | Seconds the advance is postponed, measured from each interaction (default 10). |
|
||
| `interaction_debounce` | Logging/trace throttle for continuous drags (default 0.5). |
|
||
| `interaction_grace` | Settle window after the last raw event still counted as interacting (default 5). |
|
||
| `max_dwell_factor` | Hard ceiling = `duration × factor`. |
|
||
| `min_max_dwell` | Floor for that hard ceiling, in seconds. |
|
||
| `launch_timeout` | How long to wait for the browser window to appear. |
|
||
| `prewarm` | Pre-warm the next weblink (disabled on Windows). |
|
||
|
||
### 6.4 Diagnostics
|
||
|
||
The watcher traces structured events through `playback_trace.py`:
|
||
`weblink_launch`, `weblink_visible`, `weblink_interaction`, `weblink_end`
|
||
(with reason `viewer_idle`, `browser_exited` or `max_dwell`),
|
||
`weblink_not_visible` and `weblink_failed`.
|