Web links were implemented three times (main.py subprocess, run_win.py subprocess + Win32 overlay, cef_browser.py embedded CEF), each owning its own process handle, watchdog and teardown. That ambiguity caused leaked browsers, skipped items, lost foreground and blank screens when a page failed to load. Replace all three with a single owner in src/weblink_session.py: - WeblinkSession: validate -> launch -> verify -> watch -> teardown. Generation-tokened so stale callbacks are ignored, idempotent close(), atexit-safe, never more than one browser alive. - WeblinkAdapter: the only platform-specific part (launch / wait_visible / is_alive / teardown / prewarm). Platform layers inject engines through SignagePlayer.weblink_adapter_factory. - ChromiumSubprocessAdapter: default engine (Pi chromium, Windows chrome/msedge). - InteractionWatcher: decides when an item is finished. - WebInputSources: /dev/input/event* (Linux) plus a GetCursorPos pointer tap (needed on Windows for embedded CEF, which has no child process). Interaction model: web links are an interactive surface, not timed media. The player advances only when the configured duration has elapsed AND the viewer has not interacted for 10s, measured from the most recent interaction. A touch in the final seconds of a slot therefore pushes the advance 10s past that touch, and each further touch pushes it again, so a page is never pulled out from under someone using it. An untouched page still advances on schedule. A drag burst counts as one interaction but the countdown tracks its last event, so an item cannot be cut off mid-gesture. max_dwell (duration x factor, floored by min_max_dwell) is an absolute backstop against a wedged browser or a jammed touchscreen. Verified start-up: the visibility wait runs on the watcher thread, never on Kivy's main thread. If the browser window never appears the item is reported failed and skipped, instead of resetting the error counter and leaving a black screen up for the whole duration. Config: new "weblink" block in config/app_config.json (engine, interaction_postpone, interaction_debounce, interaction_grace, max_dwell_factor, min_max_dwell, launch_timeout, prewarm) with safe defaults, so an absent block still works. Also add weblink_session to the PyInstaller hiddenimports so the frozen exe bundles the new module.
14 KiB
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
weblinkitems on both Raspberry Pi (chromiumsubprocess) and Windows (embedded CEF with a Chrome/Edge subprocess fallback). 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:
{
"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:
- Syncs (
src/get_playlists_v2.py→download_media_files()): downloadsurlinto the localmedia/directory, then rewrites each item keeping onlyfile_name,url(now a local relative path),duration,edit_on_player. Note: thetypefield is currently discarded here. - 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:
{
"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, ...)
- Skip download for web links. At the top of the per-item loop, detect
media.get('type') == 'weblink'and do not callsession.get()/ write any file for it. - Preserve
typeand the originalurl. Theupdated_mediadict that is appended toupdated_playlistcurrently dropstypeand rewritesurlto a local path. It must now carrytypethrough, and for web links keepurlas the original web address (do not convert to a local relative path).
Suggested shape of the per-item logic:
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),
})
delete_unused_media()walksmedia/usingfile_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 aweblinkitem 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:
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
durationseconds, then advance withself.next_media(). - Wrap in
try/except; on failure incrementself.consecutive_errorsand callself.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:
-
Chromium kiosk overlay (recommended). Launch Chromium over the Kivy window for the item's duration, then close it and return to Kivy:
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-browseron 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.
- Install Chromium on the player image (
-
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. -
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
imageitem; 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 whentype == 'weblink'.get_playlists_v2.py: preservetypein the rewritten playlist items (fixes the current loss oftype).get_playlists_v2.py: keep the original weburlfor weblink items.main.pyplay_current_media(): branch toplay_weblink()before theos.stat()file check.main.py: implementplay_weblink(url, duration)(Chromium kiosk).main.py: validate scheme ishttp/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/httpsschemes on both server and player; never openfile://,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. |
ChromiumSubprocessAdapter |
Default engine (Raspberry Pi chromium, Windows chrome.exe/msedge.exe). |
InteractionWatcher |
Decides when the item is finished (see the interaction model below). |
WebInputSources |
Reads /dev/input/event* (Linux) and does a pointer-position tap (Windows, needed for embedded CEF). |
Platform wrappers inject their engines through
SignagePlayer.weblink_adapter_factory:
- Raspberry Pi / Linux — built-in Chromium subprocess adapter.
- Windows (
windows/run_win.py) — embedded CEF first (cef_browser.py, renders inside the Kivy window: no z-order fights, no subprocess), then the Chrome/Edge subprocess adapter as fallback.
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:
- the configured
durationhas elapsed; and - the viewer has not interacted with the page for
interaction_postponeseconds (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 leastmin_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:
"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.