Files
Kiwy-Signage/PLAYER_WEBLINK_INTEGRATION.md
T
ske087 3ac7f836c4 Port the player to Raspberry Pi OS Trixie 64-bit (Linux-only branch)
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.
2026-09-13 21:57:49 +03:00

385 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 14 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`.