Files
Kiwy-Signage/PLAYER_WEBLINK_INTEGRATION.md
T
ske087 9f5409685d Embedded WebView2 engine for web links (Windows)
Web links previously launched a separate Chrome/Edge kiosk process, which
caused the whole class of bugs in the tracker: the browser opening behind the
player, being handed off to an already-running instance and exiting instantly,
fighting for foreground/z-order, and leaking msedge.exe/chrome.exe processes
that were never closed.

WebView2 renders as a CHILD HWND of Kivy's own SDL window instead, so there is
no separate top-level browser to open behind the player, nothing to hand the
URL off to, no z-order contest, and no leaked browser process.

Windows/webview2_browser.py
  - Environment -> controller -> navigate, driven through pythonnet.
  - Async .NET Tasks are polled from Kivy's Clock. Calling GetAwaiter()
    .GetResult() would deadlock: the continuation needs the same thread's
    message pump.
  - The controller is a .NET IntPtr, not a Python int (CreateAsync overloads
    do not match otherwise).
  - NavigationCompleted is tracked so a page that never loads can be told
    apart from one that did. This matters on a closed network: an unreachable
    host paints a Chromium error page, and without this the player would show
    a blank/error screen for the item's whole slot instead of skipping it.
  - is_alive() reports True while starting up. Start-up is async, so a
    controller that does not exist yet is not a dead browser; treating it as
    one made the first web link after a cold start be skipped instantly.

Windows/webview2_runtime.py
  - Detects the Runtime (registry pv value, SDK probe as fallback) and
    installs it silently when missing, unelevated, which produces a per-user
    install and therefore never raises a UAC prompt on the signage display.
  - Success is decided by RE-READING the installed version, not by the
    installer exit code: Edge Update returns a non-zero HRESULT
    (-2147219416) when the Runtime is already current, which is not a failure.
  - On a closed network the online bootstrapper can never succeed, so it fails
    fast with an actionable message instead of hanging for the full timeout.
  - Failed attempts are cooldown-gated so a broken machine does not re-run an
    installer on every start.

Offline hardening
  - Browser arguments disable component updates, field trials, safe-browsing
    list fetches, translate and other internet chatter. On an isolated LAN
    each of those would otherwise have to time out, costing start-up latency.
    Pages on the local server are unaffected.

Engine order (best first): WebView2 -> CEF -> Chrome/Edge subprocess. CEF has
no wheels past Python 3.9 so it is dormant on this build; the subprocess engine
remains only as a last resort.

Also fixes the reason the Windows adapters were never used at all:
SignagePlayer.__init__ assigned self.weblink_adapter_factory = None, which
shadowed the CLASS attribute that run_win.py injects. play_weblink() therefore
fell back to the generic adapter, whose find_browser() uses shutil.which() and
finds nothing on Windows because Chrome/Edge are not on PATH. The instance
attribute is now only set when the class attribute is absent.

Verified: windows/test_webview2_embed.py, test_webview2_navigation.py and
test_webview2_offline.py all pass (a locally served page renders with all
internet traffic disabled), and the packaged exe reports
"weblink_launch engine=webview2-embedded" -> "weblink_launched" on every cycle
with no leaked browser processes.
2026-09-13 10:14:18 +03:00

16 KiB
Raw Blame History

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 both Raspberry Pi (chromium subprocess) and Windows (embedded WebView2, with the CEF and Chrome/Edge subprocess engines as fallbacks). 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:

{
  "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.pydownload_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.pyplay_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, ...)

  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:

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),
})
  1. 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:

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:

    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.

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; on Windows the Chrome/Edge fallback).
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 engines).
webview2_browser.py Windows, preferred: embeds WebView2 as a child HWND of the Kivy window.
webview2_runtime.py Windows: detects the WebView2 Runtime and installs it silently when missing.

Platform wrappers inject their engines through SignagePlayer.weblink_adapter_factory:

  • Raspberry Pi / Linux — built-in Chromium subprocess adapter.
  • Windows (windows/run_win.py) — engines are tried in this order:
    Order Engine Renders Notes
    1 WebView2 (webview2_browser.py) child window inside Kivy Preferred. No subprocess, so no background/z-order/hand-off/leak problems.
    2 CEF (cef_browser.py) child window inside Kivy Dormant: cefpython3 has no wheels past Python 3.9.
    3 Chrome/Edge subprocess separate window Last resort only; retains the old drawbacks.

WeblinkAdapter.extra_launch_args() is the hook subclasses use to add flags without duplicating launch() — the Chrome 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 Windows wrapper 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 Windows: WebView2 Runtime

WebView2 is two separate things, and they ship differently:

  • the SDK (Microsoft.Web.WebView2.Core.dll, WebView2Loader.dll) — the API surface, bundled in the exe from windows\webview2_sdk\ (~860 KB);
  • the Runtime (msedgewebview2.exe) — the actual Chromium engine, shipped by Microsoft and verified/installed at start-up by windows\webview2_runtime.py.

If the Runtime is absent the player runs an installer silently (/silent /install) and unelevated, which produces a per-user install and therefore never raises a UAC prompt on the signage display. A ~1.7 MB online bootstrapper is bundled by default; a ~203 MB offline standalone installer can be bundled instead (see windows\webview2_runtime\download_runtime_installers.ps1) for machines with no internet.

Install success is decided by re-reading the installed version, not by the installer exit code — Edge Update returns a non-zero HRESULT (e.g. -2147219416) when the Runtime is already current, which is not a failure.

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:

"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.