# Web Link Playlist Items — Player Integration Guide This document describes how the **Kiwy-Signage player** () 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-`), **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=/.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`.