ScreenTinker v2.0.9
Added
Live video (WebRTC). An optional path that shows sub-second video of what a screen is actually
playing, alongside the existing screenshot stream. With a go2rtc
sidecar and a publishing player, one screen can be watched by many dashboards without asking the
device to encode a separate stream per viewer; ScreenTinker is only ever the signaling proxy and
never becomes an SFU. Off by default at every level (server master gate LIVE_VIDEO_ENABLED, per
workspace, per device), so enabling the sidecar never silently starts streaming. The Android
publisher was rewritten onto a WebSocket + trickle-ICE path (native libwebrtc drops the inline
HTTP-answer candidates that browsers tolerate), and its foreground service was hardened against the
ANR and native crashes that came from disposing a PeerConnection from its own callback. The
dashboard plays WebRTC first, then falls back to MSE/HLS, then to the screenshot stream. Documented
in docs/live-video.md.
A VP8/VP9-capable go2rtc image. Stock go2rtc registers only H264/H265 for WebRTC ingest, so a
publisher with no H264 encoder gets its video rejected and no frames flow. This is invisible for
browsers and real phones (all offer H264) but bites the Android emulator, whose only H264 codec is a
software encoder libwebrtc excludes. docker/go2rtc-vp8/ builds an image that adds VP8/VP9 to the
receive set with a one-function patch, for emulator testing and unusual hardware. Real hardware does
not need it.
Talk: voice intercom and PA broadcast. Operators can now talk to screens over the same go2rtc
path, in Opus. Per device, Talk is one-way by default (operator mic to the screen's speaker, so it
works on a mic-less display) with a separate 2-way button that appears only when the device declares
a microphone. Per group and per workspace, a one-way PA broadcast fans a single operator stream out
to every device in scope, listen-only, so a whole group's mics never mix into noise. The operator
can optionally share a webcam, shown fullscreen over the content on the screen; content audio and
video duck while talk is active and restore on stop, and hardware echo-cancellation on the device
stops the operator hearing themselves. Talk is fail-soft: any failure just leaves no audio and never
touches playback or live video.
Talk is gated per organization, with a per-org TURN/STUN. Talk is off by default and enabled per
org: a global TALK_ENABLED master switch plus each organization's own talk_enabled flag, both
required. A platform admin sets it under Admin → Organizations, where an organization can also
provide its own ICE (STUN/TURN) servers — a JSON override that applies to that org's live video
and talk alike, falling back to the sidecar's GO2RTC_* servers when unset. lib/org-webrtc.js
resolves both from a device or workspace up to its org, and every talk endpoint enforces the flag
server-side.
Trial expiry actually happens. The 14-day Pro trial used to end only when the user next opened
Billing, paired a screen, uploaded content, or a screen reconnected; anyone who went quiet stayed on
Pro indefinitely (283 of 421 hosted accounts on 2026-09-12). A nightly sweep (services/trialExpiry.js,
14:00 UTC) now moves every lapsed trial to Free through the same expireTrial() the lazy path uses,
and pushes each affected screen its access-gated playlist so an extra screen that is connected stops
at once. Two emails go with it, once per user: "your Pro trial ends in N days" at three days out, and
"your Pro trial has ended" after the downgrade, each naming which screens stop and what it costs to
keep them. Off entirely under SELF_HOSTED=true; emails also need HOSTED_INSTANCE=true. The
expiry email only reaches trials that lapsed within TRIAL_EXPIRED_EMAIL_MAX_AGE_DAYS (default 30),
so the first sweep on a deep backlog downgrades silently rather than mailing months-old signups. New
columns users.trial_expired_at, trial_ending_email_sent_at, trial_expired_email_sent_at; the
Billing page shows "Your Pro trial ended on …" for a downgraded account.
Fixed
A dashboard push could re-enable a paywalled screen. Only the three device-register paths
consulted checkDeviceAccess; the ~20 dashboard-side pushes (playlist, content, layout, widget and
group edits, the scheduler, mute-sync, data-source refresh, video walls, releases) and the offline
flush in lib/command-queue all sent the raw playlist, so assigning content to a blocked screen
brought it back until its next reconnect. buildPlaylistPayload — the only builder exported for
delivery — is now the gated one; the dashboard preview uses buildPlaylistPayloadUnchecked.
Blocked screens said "Device Limit Reached" instead of "Trial Expired". The trial-expired branch
was keyed on trial_started, which the downgrade nulls first, so it could never fire. It now keys on
trial_expired_at and tells the owner their trial ended.
Artifacts
screentinker-2.0.9.tar.gz- bundle: server + frontend source + the Tizen .wgt (the signed Android APK is added at the root during release finalization).ScreenTinker.wgt- Tizen TV web app, unsigned - for inspection only.
Sign it with your own Samsung certificate (Tizen Studio + a profile that includes
your TV's DUID) to install, or - easiest - point a Tizen TV browser / URL Launcher
athttps://<your-instance>/player(no signing needed).ScreenTinker.ipk- LG webOS Signage app. Install from USB or an SI server; it then
updates itself from your instance. Built without LG's SCAP library (not redistributable),
so power and self-update controls need a build fromwebos/build-ipk.shwith the SDK
files inwebos/vendor/; seedocs/webos-player.md.autorun.zip- BrightSign player installer. Drop it on the root of a player's
storage (microSD, USB, or internal flash) and power-cycle: it unpacks itself and
reboots into the player. Editscreentinker.jsoninside the archive first to
point it at your own server.autorun-server.zip+server-payload.zip+server-payload.json- make a
BrightSign run the ScreenTinker server rather than just a player. Drop the
first on the player's storage root with anst-config.jsoncontaining
{"server": 1}; it fetches the payload on boot. PointpayloadUrlat your
own host - the default reaches ours, which is wrong for your site. The manifest
must sit beside the payload wherever you serve it: the launcher reads it to
decide whether an update is published.- Docker image:
ghcr.io/screentinker/screentinker:2.0.9(also:latest). ScreenTinker.apk- signed Android player (attached during release finalization).screentinker-sbom-2.0.9.cdx.json- CycloneDX 1.5 software bill of materials for the server's production dependencies, with the licence of every component.