mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
Tizen player 1.9.1-beta3: bug fixes, multi-zone layouts, video walls
Brings the Tizen TV player to parity with the other players: closes the five Tizen issues Bold Media Group filed (#118-#122) and adds the two larger renderer features it was still missing. Fixes (#118-#122) - #118 Sticky "Not authenticated" banner. On TV sleep/wake the socket reconnects and a heartbeat could land on the fresh, not-yet-registered socket; the server rejected it and the old handler painted a permanent banner AND dropped the saved credentials, forcing a re-pair. Heartbeats are now gated on a per-connection authenticated flag (true only between device:registered and disconnect/auth-error), the heartbeat stops on connect/disconnect/auth-error, the banner clears on device:registered, and the auth-error toast is non-sticky. - #119 app_version stuck at 1.0.0. Resolved at runtime from config.xml via the Tizen application API, with a fallback constant that build-wgt.sh stamps from config.xml. - #121 Remote commands. Added a device:command handler (refresh/launch/screen_on/ screen_off; honest no-op toasts for update/reboot/shutdown, which need B2B/MDM privileges a sideloaded app lacks). Removed the dead device:reload listener. - #120 Dashboard preview. Added device:screenshot-request + remote-start/remote-stop. Images capture; video/YouTube fall back to a status card (TV hardware video plane and cross-origin iframes can't be read into a canvas). - #122 Updates/boot. Documented the real paths (re-sideload or URL Launcher/MDM refresh; display-level kiosk/boot settings) since a sideloaded .wgt has no in-app OTA or config.xml autostart. Multi-zone layouts (Android parity) - New ZoneRenderer ports the Android ZoneManager: zones positioned by percent geometry with z_index/fit_mode/background, assignments grouped by zone_id (unassigned content goes to the first zone), each zone rotating independently with the same per-item schedule gating (#74/#75). app.js selects the renderer from payload.layout; single-zone playback is unchanged. Video walls (web-player parity; Android has none) - New WallController mirrors the web player: when payload.wall_config is present the stage is positioned (vw/vh) as this screen's slice of the wall. The leader plays normally and broadcasts wall:sync at 4Hz; followers hold the leader's item, align index, and lock their video to the leader's clock with a latency-compensated drift controller (hard-seek past 0.3s, gentle +/-3% playbackRate nudge past 0.05s), and request an immediate position on (re)connect via wall:sync-request. Per-tile rotation is not applied yet (matches the web player). Wall emits are gated on auth + connection so a pre-register tick can't trip device:auth-error. Not ported: video-wall per-tile rotation, plus the minor Android-only reporting events (device:playback-state, device:log) and the N/A offline-cache events (device:content-ack/content-delete). None affect on-screen playback. Verified: JS syntax + headless unit tests of zone grouping/geometry and wall leader/follower + drift logic. NOT yet validated on Tizen hardware - multi-screen video sync in particular needs a real wall to tune.
This commit is contained in:
parent
0cd2a904e5
commit
9c4b48800f
46
CHANGELOG.md
46
CHANGELOG.md
|
|
@ -1,5 +1,51 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.9.1-beta3 — unreleased
|
||||||
|
|
||||||
|
### Fixed — Tizen player
|
||||||
|
- **#118 Sticky "Not authenticated" banner.** On TV sleep/wake the socket reconnects and
|
||||||
|
a heartbeat could fire on the fresh, not-yet-registered socket; the server rejected it
|
||||||
|
with `device:auth-error`, which the player showed as a *sticky* toast over still-playing
|
||||||
|
content (and, worse, dropped its saved credentials and re-paired). Heartbeats are now
|
||||||
|
gated on a per-connection `authenticated` flag (set only between `device:registered` and
|
||||||
|
`disconnect`/`auth-error`), the heartbeat timer is stopped on `connect`/`disconnect`/
|
||||||
|
`auth-error`, the stale banner is cleared on `device:registered`, and the `auth-error`
|
||||||
|
toast is non-sticky so any transient case self-clears.
|
||||||
|
- **#119 `app_version` stuck at `1.0.0`.** The hardcoded constant made every Tizen device
|
||||||
|
report `1.0.0` regardless of the installed `.wgt`. The version now resolves at runtime
|
||||||
|
from `config.xml` via the Tizen application API, with a fallback constant that
|
||||||
|
`build-wgt.sh` stamps from `config.xml`'s `version=""`.
|
||||||
|
|
||||||
|
### Added — Tizen player
|
||||||
|
- **Video walls (`wall:sync`).** The Tizen player now supports wall membership: when the
|
||||||
|
payload carries `wall_config`, a new `WallController` positions the stage (vw/vh) as this
|
||||||
|
screen's slice of the wall and drives the single-zone player as leader or follower. The
|
||||||
|
leader broadcasts `wall:sync` at 4Hz; followers align their index and keep their video
|
||||||
|
locked to the leader's clock with a latency-compensated drift controller (hard-seek past
|
||||||
|
0.3s, gentle ±3% playbackRate nudge past 0.05s), and request an immediate position on
|
||||||
|
(re)connect via `wall:sync-request`. Mirrors the web player (the Android player has no
|
||||||
|
wall support). Per-tile `rotation` is not applied yet (web-player parity). Wall emits are
|
||||||
|
gated on auth + connection so a pre-register tick can't trip `device:auth-error`.
|
||||||
|
- **Multi-zone layouts (Android parity).** The Tizen player now renders assigned layouts,
|
||||||
|
not just fullscreen single-zone. A new `ZoneRenderer` (ports the Android `ZoneManager`)
|
||||||
|
positions zones by percent geometry with `z_index`/`fit_mode`/background, groups
|
||||||
|
assignments by `zone_id` (unassigned content goes to the first zone), and rotates each
|
||||||
|
zone independently with the same per-item schedule gating (#74/#75). `app.js` selects the
|
||||||
|
renderer from `payload.layout`; single-zone playback is unchanged. (Video walls
|
||||||
|
`wall:sync` are still Android-only.)
|
||||||
|
- **#121 Remote commands.** Added a `device:command` handler (`refresh`, `launch`,
|
||||||
|
`screen_on`, `screen_off`, plus honest no-op toasts for `update`/`reboot`/`shutdown`,
|
||||||
|
which need B2B/MDM privileges a sideloaded app lacks). Removed the dead `device:reload`
|
||||||
|
listener (the server never emitted it) in favour of `device:command` `refresh`.
|
||||||
|
- **#120 Dashboard preview.** Added `device:screenshot-request` / `device:remote-start` /
|
||||||
|
`device:remote-stop`. Images capture for real; `<video>`/YouTube fall back to a status
|
||||||
|
card because the TV's hardware video plane and cross-origin iframes can't be read into a
|
||||||
|
`<canvas>`. See `tizen/README.md` for the support matrix.
|
||||||
|
- **#122 Updates / boot.** Documented the supported paths — `.wgt` re-sideload or URL
|
||||||
|
Launcher/MDM refresh for updates, and display-level kiosk/URL-Launcher settings for
|
||||||
|
auto-launch on boot (there is no in-app OTA or `config.xml` autostart for a sideloaded
|
||||||
|
consumer TV web app).
|
||||||
|
|
||||||
## 1.9.0 — 2026-06-11
|
## 1.9.0 — 2026-06-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,21 @@ display pairs and plays from the same dashboard with no server changes.
|
||||||
- Registers, shows a **6-digit pairing code**; you claim it in the dashboard
|
- Registers, shows a **6-digit pairing code**; you claim it in the dashboard
|
||||||
(Devices → Pair a display). On `device:paired` it switches to playback.
|
(Devices → Pair a display). On `device:paired` it switches to playback.
|
||||||
- Reconnects automatically with a stored `device_id` + `device_token`.
|
- Reconnects automatically with a stored `device_id` + `device_token`.
|
||||||
- Renders **fullscreen single-zone** playlists, looping:
|
- Renders **multi-zone layouts** (matching the Android player) when a layout is assigned —
|
||||||
|
each zone has its own percent geometry, `z_index`, `fit_mode`, background, and rotates its
|
||||||
|
own assignments independently — and falls back to **fullscreen single-zone** when no
|
||||||
|
layout is set, looping:
|
||||||
- **image** → shown for `duration_sec` (min 3s)
|
- **image** → shown for `duration_sec` (min 3s)
|
||||||
- **video** (`/api/content/{id}/file` or `remote_url`) → plays to end, then next; single item loops
|
- **video** (`/api/content/{id}/file` or `remote_url`) → plays to end, then next; single item loops
|
||||||
- **YouTube** (`mime video/youtube`) → muted autoplay `<iframe>` embed
|
- **YouTube** (`mime video/youtube`) → muted autoplay `<iframe>` embed
|
||||||
- **widget** → `<iframe>` of `{server}/api/widgets/{id}/render`
|
- **widget** → `<iframe>` of `{server}/api/widgets/{id}/render`
|
||||||
- Sends `device:heartbeat` every 15s (with best-effort Tizen telemetry).
|
- Sends `device:heartbeat` every 15s (with best-effort Tizen telemetry).
|
||||||
- Keeps the screen awake (`tizen.power` / Samsung `appcommon` screensaver-off).
|
- Keeps the screen awake (`tizen.power` / Samsung `appcommon` screensaver-off).
|
||||||
|
- **Video walls** (mirrors the web player): when the device is a wall member the payload
|
||||||
|
carries `wall_config`; the stage is positioned (in vw/vh) as this screen's slice of the
|
||||||
|
wall, the leader broadcasts `wall:sync` and followers align index + drift-correct their
|
||||||
|
video to the leader's clock. Per-tile `rotation` is not applied yet (matches the web
|
||||||
|
player); video walls have no Android equivalent.
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
```
|
```
|
||||||
|
|
@ -79,7 +87,50 @@ lives in `~/tizen-studio-data`, password `screentinker`).
|
||||||
- **Runtime**: loads + renders in Chromium with no JS errors (setup screen verified).
|
- **Runtime**: loads + renders in Chromium with no JS errors (setup screen verified).
|
||||||
- Not yet on real Tizen hardware — needs signing + a TV (or URL Launcher).
|
- Not yet on real Tizen hardware — needs signing + a TV (or URL Launcher).
|
||||||
|
|
||||||
## Not yet ported (Android player has these; fullscreen single-zone covers most signage)
|
## Remote control & preview (#120 / #121)
|
||||||
Multi-zone layouts, video walls (`wall:sync`), screenshots, remote touch/control,
|
The Tizen player now listens for the same dashboard events as the web/Android player.
|
||||||
and self-OTA (Tizen apps update via Samsung's store / URL Launcher refresh, not the
|
What it can actually do depends on what a **sideloaded web app** is allowed to do on
|
||||||
Android `PackageInstaller` flow).
|
the TV runtime:
|
||||||
|
|
||||||
|
| Command (`device:command` type) | Tizen behaviour |
|
||||||
|
|-----------------------------------|-----------------------------------------------------------|
|
||||||
|
| `refresh` | `location.reload()` |
|
||||||
|
| `launch` / `screen_on` | clears the screen-off overlay + re-requests screen-awake |
|
||||||
|
| `screen_off` | black full-screen overlay (content keeps running behind) |
|
||||||
|
| `update` | toast: must re-install the `.wgt` (see **Updates** below) |
|
||||||
|
| `reboot` / `shutdown` | MDM-only — not reachable from a sideloaded app (toast) |
|
||||||
|
| `device:screenshot-request` | best-effort capture (see note) |
|
||||||
|
| `device:remote-start` / `-stop` | start/stop ~1 fps preview streaming |
|
||||||
|
|
||||||
|
> **Screenshot/preview note:** the TV decodes `<video>` onto a hardware overlay plane
|
||||||
|
> and plays YouTube in a cross-origin `<iframe>`, neither of which can be read back into
|
||||||
|
> a `<canvas>`. So **images capture for real; video/YouTube fall back to a status card**
|
||||||
|
> (device + timestamp). The dashboard preview shows a truthful frame rather than a dead
|
||||||
|
> button. Full-fidelity video preview isn't feasible on the sideloaded Tizen runtime.
|
||||||
|
|
||||||
|
> **`screen_off`** uses an overlay, not a real panel power-off — a sideloaded app has no
|
||||||
|
> clean panel-power API. On B2B/MDM (SSSP) firmware, true power and `reboot`/`shutdown`
|
||||||
|
> go through Samsung's device-management channel, not this app.
|
||||||
|
|
||||||
|
## Updates (#122)
|
||||||
|
There is **no in-app OTA** for a sideloaded, signed `.wgt`. Updating a screen means
|
||||||
|
**re-building and re-sideloading** the `.wgt` (path B above), or — on Samsung B2B
|
||||||
|
signage — pushing it through the **URL Launcher refresh / MDM (MagicINFO / SSSP)**
|
||||||
|
channel. The dashboard `update` command therefore just tells the screen an update is
|
||||||
|
pending; it cannot self-apply. If you run the **URL Launcher path (A)**, a plain
|
||||||
|
TV reboot re-fetches `…/player` and you're current with the server with no `.wgt` step.
|
||||||
|
|
||||||
|
## Auto-launch on boot (#122)
|
||||||
|
Boot auto-start for a **sideloaded** consumer TV web app is a **display setting, not an
|
||||||
|
app setting** — there's no `config.xml` autostart for the TV profile. Configure it on
|
||||||
|
the panel:
|
||||||
|
- **URL Launcher path (A):** set the URL Launcher as the boot app (it relaunches on
|
||||||
|
power-up automatically) — the recommended signage setup.
|
||||||
|
- **Signed-app path (B):** use the TV's **kiosk / auto-start app** setting (B2B/SSSP
|
||||||
|
firmware) to launch ScreenTinker on boot; on dev-mode consumer TVs there's no
|
||||||
|
guaranteed boot-launch, so the URL Launcher path is preferred for unattended screens.
|
||||||
|
|
||||||
|
## Version reporting (#119)
|
||||||
|
`app_version` is sourced from `config.xml`'s `version=""` — read at runtime via the
|
||||||
|
Tizen application API, with a build-stamped constant fallback (`build-wgt.sh` stamps it
|
||||||
|
from `config.xml`). The dashboard always shows the version actually installed.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,15 @@ rm -f "$OUT"
|
||||||
# .wgt always ships the canonical (byte-identical) copy, never a stale duplicate.
|
# .wgt always ships the canonical (byte-identical) copy, never a stale duplicate.
|
||||||
cp ../server/lib/schedule-eval.js js/schedule-eval.js
|
cp ../server/lib/schedule-eval.js js/schedule-eval.js
|
||||||
|
|
||||||
|
# #119: stamp the player version from the single source (config.xml) so the .wgt's
|
||||||
|
# reported app_version always matches what is installed — same idea as the copy above.
|
||||||
|
VER="$(grep -v '<?xml' config.xml | grep -oE 'version="[0-9][^"]*"' | head -1 | sed -E 's/version="([^"]+)"/\1/')"
|
||||||
|
if [ -n "$VER" ]; then
|
||||||
|
sed -i.bak "s/var APP_VERSION_FALLBACK = '[^']*';/var APP_VERSION_FALLBACK = '$VER';/" js/app.js
|
||||||
|
rm -f js/app.js.bak
|
||||||
|
echo "Stamped APP_VERSION_FALLBACK = $VER from config.xml."
|
||||||
|
fi
|
||||||
|
|
||||||
if command -v tizen >/dev/null 2>&1; then
|
if command -v tizen >/dev/null 2>&1; then
|
||||||
PROFILE="${1:-ScreenTinker}"
|
PROFILE="${1:-ScreenTinker}"
|
||||||
echo "Tizen CLI found — signing with profile '$PROFILE'…"
|
echo "Tizen CLI found — signing with profile '$PROFILE'…"
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,11 @@ button.ghost { background: transparent; color: #64748b; font-size: 18px; margin-
|
||||||
.stage img.cover, .stage video.cover { object-fit: cover; }
|
.stage img.cover, .stage video.cover { object-fit: cover; }
|
||||||
.stage img.fill, .stage video.fill { object-fit: fill; }
|
.stage img.fill, .stage video.fill { object-fit: fill; }
|
||||||
|
|
||||||
|
/* Video wall mode: the stage is positioned (in vw/vh) as this screen's slice of the
|
||||||
|
wall's player rect; media stretches to fill (object-fit:fill) so a given source row
|
||||||
|
lands on the same physical line across every screen that shares a viewport height. */
|
||||||
|
.stage.wall-mode img, .stage.wall-mode video, .stage.wall-mode iframe { object-fit: fill; }
|
||||||
|
|
||||||
/* Toast */
|
/* Toast */
|
||||||
.toast {
|
.toast {
|
||||||
position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
|
position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||||
|
|
|
||||||
185
tizen/js/app.js
185
tizen/js/app.js
|
|
@ -11,7 +11,18 @@
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var APP_VERSION = '1.0.0';
|
// #119: one source of truth for the player version. Resolve at runtime from the
|
||||||
|
// packaged config.xml via the Tizen application API; fall back to a constant that
|
||||||
|
// build-wgt.sh stamps from config.xml's version="" so the dashboard always shows the
|
||||||
|
// version that is actually installed (never the old hardcoded '1.0.0').
|
||||||
|
var APP_VERSION_FALLBACK = '1.9.1'; // st:app-version — stamped by build-wgt.sh
|
||||||
|
var APP_VERSION = (function () {
|
||||||
|
try {
|
||||||
|
var v = tizen.application.getCurrentApplication().appInfo.version;
|
||||||
|
if (v) return v;
|
||||||
|
} catch (e) {}
|
||||||
|
return APP_VERSION_FALLBACK;
|
||||||
|
})();
|
||||||
var HEARTBEAT_MS = 15000;
|
var HEARTBEAT_MS = 15000;
|
||||||
var DEFAULT_DURATION = 10;
|
var DEFAULT_DURATION = 10;
|
||||||
var MIN_DURATION = 3;
|
var MIN_DURATION = 3;
|
||||||
|
|
@ -80,6 +91,8 @@
|
||||||
var serverUrl = get(LS.url);
|
var serverUrl = get(LS.url);
|
||||||
var heartbeatTimer = null;
|
var heartbeatTimer = null;
|
||||||
var beatCount = 0;
|
var beatCount = 0;
|
||||||
|
var authenticated = false; // #118: true only between device:registered and disconnect/auth-error
|
||||||
|
var streamTimer = null; // #120: dashboard preview streaming interval
|
||||||
|
|
||||||
function deviceInfo() {
|
function deviceInfo() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -122,6 +135,11 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('connect', function () {
|
socket.on('connect', function () {
|
||||||
|
// #118: a brand-new socket is not authenticated until device:registered. Reset the
|
||||||
|
// flag and kill any heartbeat carried over from the previous socket, so a beat can't
|
||||||
|
// fire on this fresh, unregistered connection (TV sleep/wake reconnects often).
|
||||||
|
authenticated = false;
|
||||||
|
stopHeartbeat();
|
||||||
clearToast();
|
clearToast();
|
||||||
register();
|
register();
|
||||||
});
|
});
|
||||||
|
|
@ -137,11 +155,17 @@
|
||||||
toast('Reconnecting…', true);
|
toast('Reconnecting…', true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
socket.on('disconnect', function () { toast('Reconnecting…', true); });
|
socket.on('disconnect', function () {
|
||||||
|
authenticated = false; // #118
|
||||||
|
stopHeartbeat(); // #118: no beats on a dead socket
|
||||||
|
toast('Reconnecting…', true);
|
||||||
|
});
|
||||||
|
|
||||||
socket.on('device:registered', function (data) {
|
socket.on('device:registered', function (data) {
|
||||||
deviceId = data.device_id; deviceToken = data.device_token;
|
deviceId = data.device_id; deviceToken = data.device_token;
|
||||||
set(LS.id, deviceId); set(LS.token, deviceToken);
|
set(LS.id, deviceId); set(LS.token, deviceToken);
|
||||||
|
authenticated = true; // #118: this socket may now send post-register events
|
||||||
|
clearToast(); // #118: drop any stale "Not authenticated…" banner
|
||||||
startHeartbeat();
|
startHeartbeat();
|
||||||
if (data.status === 'provisioning') showPairing();
|
if (data.status === 'provisioning') showPairing();
|
||||||
});
|
});
|
||||||
|
|
@ -157,8 +181,13 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('device:auth-error', function (data) {
|
socket.on('device:auth-error', function (data) {
|
||||||
|
// #118: NEVER sticky. A transient pre-register rejection must self-clear, not paint
|
||||||
|
// a permanent strip over still-playing content. Stop the heartbeat so a rejected beat
|
||||||
|
// can't sustain a reject -> auth-error loop.
|
||||||
|
authenticated = false;
|
||||||
|
stopHeartbeat();
|
||||||
|
toast((data && data.error) ? data.error : 'Auth error', false);
|
||||||
// Bad/stale token or fingerprint-reclaim block: drop creds and re-pair.
|
// Bad/stale token or fingerprint-reclaim block: drop creds and re-pair.
|
||||||
toast((data && data.error) ? data.error : 'Auth error', true);
|
|
||||||
del(LS.id); del(LS.token);
|
del(LS.id); del(LS.token);
|
||||||
deviceId = null; deviceToken = null;
|
deviceId = null; deviceToken = null;
|
||||||
setTimeout(register, 3000);
|
setTimeout(register, 3000);
|
||||||
|
|
@ -166,8 +195,47 @@
|
||||||
|
|
||||||
socket.on('device:playlist-update', onPlaylist);
|
socket.on('device:playlist-update', onPlaylist);
|
||||||
|
|
||||||
// Optional remote commands the dashboard may send (best-effort)
|
// ---- remote control from the dashboard (#120 / #121) ----
|
||||||
socket.on('device:reload', function () { location.reload(); });
|
// Mirror the web/Android player. The server emits device:command with the set in
|
||||||
|
// server/routes/device-groups.js (ALLOWED_COMMANDS) plus 'refresh', and the
|
||||||
|
// screenshot/remote events below. (The old device:reload listener was dead — the
|
||||||
|
// server never emits it — so 'refresh' replaces it.)
|
||||||
|
socket.on('device:command', function (data) {
|
||||||
|
switch (data && data.type) {
|
||||||
|
case 'refresh':
|
||||||
|
location.reload();
|
||||||
|
break;
|
||||||
|
case 'launch':
|
||||||
|
case 'screen_on':
|
||||||
|
clearScreenOff();
|
||||||
|
keepAwake();
|
||||||
|
break;
|
||||||
|
case 'screen_off':
|
||||||
|
showScreenOff();
|
||||||
|
break;
|
||||||
|
case 'update':
|
||||||
|
// #122: a sideloaded, signed .wgt has no in-app OTA path. Surface it rather
|
||||||
|
// than dropping it silently — real updates are a re-sideload / B2B-MDM push.
|
||||||
|
toast('Update must be re-installed (.wgt sideload / MDM)', false);
|
||||||
|
break;
|
||||||
|
case 'reboot':
|
||||||
|
case 'shutdown':
|
||||||
|
tryPowerControl(data.type);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// #120: dashboard preview — single shot and start/stop streaming.
|
||||||
|
socket.on('device:screenshot-request', function () { captureAndSend(); });
|
||||||
|
socket.on('device:remote-start', function () { startStreaming(); });
|
||||||
|
socket.on('device:remote-stop', function () { stopStreaming(); });
|
||||||
|
|
||||||
|
// ---- video wall sync (mirrors the web player) ----
|
||||||
|
// Leader broadcasts position; followers align index + drift-correct their video.
|
||||||
|
socket.on('wall:sync', function (d) { wallController.onSync(d); });
|
||||||
|
socket.on('wall:sync-request', function (d) { wallController.onSyncRequest(d); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function register() {
|
function register() {
|
||||||
|
|
@ -183,17 +251,93 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function startHeartbeat() {
|
function startHeartbeat() {
|
||||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
stopHeartbeat();
|
||||||
heartbeatTimer = setInterval(function () {
|
heartbeatTimer = setInterval(function () {
|
||||||
if (!socket || !deviceId) return;
|
// #118: only beat on a socket that finished device:register, or the server's
|
||||||
|
// requireDeviceAuth() rejects the beat with device:auth-error.
|
||||||
|
if (!socket || !socket.connected || !deviceId || !authenticated) return;
|
||||||
socket.emit('device:heartbeat', { device_id: deviceId, telemetry: telemetry() });
|
socket.emit('device:heartbeat', { device_id: deviceId, telemetry: telemetry() });
|
||||||
// Every 4th beat (~60s) ask for a fresh playlist, matching the Android player.
|
// Every 4th beat (~60s) ask for a fresh playlist, matching the Android player.
|
||||||
if ((++beatCount % 4) === 0) socket.emit('device:heartbeat', { device_id: deviceId, telemetry: telemetry() });
|
if ((++beatCount % 4) === 0) socket.emit('device:heartbeat', { device_id: deviceId, telemetry: telemetry() });
|
||||||
}, HEARTBEAT_MS);
|
}, HEARTBEAT_MS);
|
||||||
}
|
}
|
||||||
|
function stopHeartbeat() {
|
||||||
|
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- remote control + dashboard preview (#120 / #121) ----
|
||||||
|
// Screen on/off uses a black overlay (a sideloaded web app can't power the panel
|
||||||
|
// off cleanly), mirroring the web player.
|
||||||
|
function showScreenOff() {
|
||||||
|
if (document.getElementById('screenOffOverlay')) return;
|
||||||
|
var o = document.createElement('div');
|
||||||
|
o.id = 'screenOffOverlay';
|
||||||
|
o.style.cssText = 'position:fixed;inset:0;background:#000;z-index:9999';
|
||||||
|
document.body.appendChild(o);
|
||||||
|
}
|
||||||
|
function clearScreenOff() {
|
||||||
|
var o = document.getElementById('screenOffOverlay');
|
||||||
|
if (o && o.parentNode) o.parentNode.removeChild(o);
|
||||||
|
}
|
||||||
|
// #121: reboot/shutdown need privileged B2B/MDM control that a sideloaded web app
|
||||||
|
// does not have. Signage firmware exposes these via the Samsung B2B (b2bapis) API,
|
||||||
|
// whose methods vary by firmware — so document it rather than failing silently.
|
||||||
|
function tryPowerControl(type) {
|
||||||
|
toast(type + ' is MDM-only on Tizen (see tizen/README.md)', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #120: best-effort dashboard preview. The Tizen TV runtime decodes <video> onto a
|
||||||
|
// hardware overlay plane and plays YouTube in a cross-origin <iframe>; neither can be
|
||||||
|
// read back into a <canvas> (drawImage yields black / throws). So video/YouTube fall
|
||||||
|
// back to a status card — the same shape as the web player's fallback — while images
|
||||||
|
// (same-origin / CORS-ok) capture for real. This gives the dashboard a truthful frame
|
||||||
|
// instead of a dead button.
|
||||||
|
function captureAndSend() {
|
||||||
|
if (!socket || !socket.connected || !deviceId || !authenticated) return;
|
||||||
|
var canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 960; canvas.height = 540;
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
var captured = false;
|
||||||
|
try {
|
||||||
|
var img = elStage.querySelector('img');
|
||||||
|
if (img && img.complete && img.naturalWidth > 0) {
|
||||||
|
try { ctx.drawImage(img, 0, 0, 960, 540); captured = true; } catch (e) {}
|
||||||
|
}
|
||||||
|
if (!captured) {
|
||||||
|
ctx.fillStyle = '#111827'; ctx.fillRect(0, 0, 960, 540);
|
||||||
|
ctx.fillStyle = '#3b82f6'; ctx.font = 'bold 28px sans-serif'; ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('ScreenTinker (Tizen)', 480, 235);
|
||||||
|
ctx.fillStyle = '#94a3b8'; ctx.font = '16px sans-serif';
|
||||||
|
ctx.fillText('Live preview unavailable for video / YouTube on Tizen', 480, 280);
|
||||||
|
ctx.fillText(new Date().toLocaleTimeString(), 480, 312);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, 960, 540);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var base64 = canvas.toDataURL('image/jpeg', 0.4).split(',')[1];
|
||||||
|
if (base64 && base64.length > 100) {
|
||||||
|
socket.emit('device:screenshot', { device_id: deviceId, image_b64: base64 });
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
function startStreaming() { stopStreaming(); streamTimer = setInterval(captureAndSend, 1000); }
|
||||||
|
function stopStreaming() { if (streamTimer) { clearInterval(streamTimer); streamTimer = null; } }
|
||||||
|
|
||||||
// ---- playback ----
|
// ---- playback ----
|
||||||
var player = new PlaylistPlayer(elStage, function () { return serverUrl.replace(/\/+$/, ''); });
|
var player = new PlaylistPlayer(elStage, function () { return serverUrl.replace(/\/+$/, ''); });
|
||||||
|
// Multi-zone layout renderer (matches the Android player). app.js picks the renderer
|
||||||
|
// per playlist-update from payload.layout; the two never run at once.
|
||||||
|
var zoneRenderer = new ZoneRenderer(elStage, function () { return serverUrl.replace(/\/+$/, ''); });
|
||||||
|
// Video-wall sync (mirrors the web player). Drives the single-zone player as leader or
|
||||||
|
// follower. canEmit gates wall emits on auth+connection so a pre-register tick can't
|
||||||
|
// trip device:auth-error (same guard rationale as the heartbeat).
|
||||||
|
var wallController = new WallController(
|
||||||
|
elStage, player,
|
||||||
|
function () { return socket; },
|
||||||
|
function () { return deviceId; },
|
||||||
|
function () { return authenticated && !!socket && socket.connected; }
|
||||||
|
);
|
||||||
|
|
||||||
// Rotate the playback stage in software for portrait / flipped signage. Tizen TVs
|
// Rotate the playback stage in software for portrait / flipped signage. Tizen TVs
|
||||||
// are fixed-landscape, so we rotate the CONTENT (not the panel). Values mirror the
|
// are fixed-landscape, so we rotate the CONTENT (not the panel). Values mirror the
|
||||||
|
|
@ -218,9 +362,11 @@
|
||||||
|
|
||||||
function onPlaylist(payload) {
|
function onPlaylist(payload) {
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
applyOrientation(payload.orientation || 'landscape');
|
|
||||||
if (payload.suspended) {
|
if (payload.suspended) {
|
||||||
player.stop();
|
player.stop();
|
||||||
|
zoneRenderer.clear();
|
||||||
|
wallController.exit();
|
||||||
|
applyOrientation(payload.orientation || 'landscape');
|
||||||
elStage.innerHTML = '<div class="card" style="position:relative"><h1>' +
|
elStage.innerHTML = '<div class="card" style="position:relative"><h1>' +
|
||||||
esc(payload.message || 'Display suspended') + '</h1><p class="sub">' +
|
esc(payload.message || 'Display suspended') + '</h1><p class="sub">' +
|
||||||
esc(payload.detail || '') + '</p></div>';
|
esc(payload.detail || '') + '</p></div>';
|
||||||
|
|
@ -230,9 +376,32 @@
|
||||||
// If we have content + we're paired, make sure we're on the stage.
|
// If we have content + we're paired, make sure we're on the stage.
|
||||||
if (elPairing.classList.contains('hidden') === false) show(elStage);
|
if (elPairing.classList.contains('hidden') === false) show(elStage);
|
||||||
else if (elStage.classList.contains('hidden')) show(elStage);
|
else if (elStage.classList.contains('hidden')) show(elStage);
|
||||||
|
|
||||||
|
if (payload.wall_config) {
|
||||||
|
// Video wall: fullscreen content mapped into this screen's slice. No multi-zone,
|
||||||
|
// and no orientation transform — the wall geometry owns the stage.
|
||||||
|
zoneRenderer.clear();
|
||||||
|
wallController.apply(payload.wall_config);
|
||||||
|
player.setTimezone(payload.timezone || null);
|
||||||
|
player.load(payload.assignments || []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wallController.exit(); // leave wall mode if we were in it
|
||||||
|
applyOrientation(payload.orientation || 'landscape');
|
||||||
|
var layout = payload.layout;
|
||||||
|
if (layout && layout.zones && layout.zones.length) {
|
||||||
|
// Multi-zone layout (matches the Android player). Leave single-zone mode first.
|
||||||
|
player.stop();
|
||||||
|
zoneRenderer.setTimezone(payload.timezone || null); // #74/#75: effective tz
|
||||||
|
zoneRenderer.render(layout, payload.assignments || []);
|
||||||
|
} else {
|
||||||
|
// Fullscreen single zone. Leave any previous zone layout first.
|
||||||
|
zoneRenderer.clear();
|
||||||
player.setTimezone(payload.timezone || null); // #74/#75: effective tz for schedule eval
|
player.setTimezone(payload.timezone || null); // #74/#75: effective tz for schedule eval
|
||||||
player.load(payload.assignments || []);
|
player.load(payload.assignments || []);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]; }); }
|
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]; }); }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ function PlaylistPlayer(stageEl, getBase) {
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
this.sig = '';
|
this.sig = '';
|
||||||
this.timezone = null; // #74/#75: device-effective IANA tz for schedule eval
|
this.timezone = null; // #74/#75: device-effective IANA tz for schedule eval
|
||||||
|
this.wallFollower = false; // video-wall: a follower holds the leader's item, no auto-advance
|
||||||
|
this.currentVideoEl = null; // current <video> (wall leader reads position; follower drift-corrects)
|
||||||
|
this.itemStartedAt = 0; // wall position fallback for non-video items
|
||||||
this.DEFAULT_DURATION = 10;
|
this.DEFAULT_DURATION = 10;
|
||||||
this.MIN_DURATION = 3;
|
this.MIN_DURATION = 3;
|
||||||
}
|
}
|
||||||
|
|
@ -99,6 +102,27 @@ PlaylistPlayer.prototype.schedule = function (ms) {
|
||||||
// always on. Fails open: any evaluator error means the item plays.
|
// always on. Fails open: any evaluator error means the item plays.
|
||||||
PlaylistPlayer.prototype.setTimezone = function (tz) { this.timezone = tz || null; };
|
PlaylistPlayer.prototype.setTimezone = function (tz) { this.timezone = tz || null; };
|
||||||
|
|
||||||
|
// ---- video-wall support (used by WallController) ----
|
||||||
|
// A follower holds the leader's current item and never auto-advances; entering or
|
||||||
|
// leaving wall mode (or a role flip) calls invalidate() so the next load re-renders
|
||||||
|
// with the right semantics instead of being de-duped by the unchanged signature.
|
||||||
|
PlaylistPlayer.prototype.setWallFollower = function (b) { this.wallFollower = !!b; };
|
||||||
|
PlaylistPlayer.prototype.invalidate = function () { this.sig = ''; };
|
||||||
|
PlaylistPlayer.prototype.getIndex = function () { return this.index; };
|
||||||
|
PlaylistPlayer.prototype.getCurrentItem = function () { return this.items[this.index] || null; };
|
||||||
|
PlaylistPlayer.prototype.getCurrentVideo = function () { return this.currentVideoEl; };
|
||||||
|
PlaylistPlayer.prototype.getItemStartedAt = function () { return this.itemStartedAt; };
|
||||||
|
// Follower jumps to the leader's index. No-op if already there (avoids a needless
|
||||||
|
// restart that would re-buffer the same item).
|
||||||
|
PlaylistPlayer.prototype.gotoIndex = function (idx) {
|
||||||
|
if (!this.items.length) return;
|
||||||
|
var n = this.items.length;
|
||||||
|
idx = ((idx % n) + n) % n;
|
||||||
|
if (idx === this.index) return;
|
||||||
|
this.index = idx;
|
||||||
|
this.playCurrent();
|
||||||
|
};
|
||||||
|
|
||||||
PlaylistPlayer.prototype.scheduleAllows = function (item) {
|
PlaylistPlayer.prototype.scheduleAllows = function (item) {
|
||||||
if (!item || !item.schedules || !item.schedules.length) return true;
|
if (!item || !item.schedules || !item.schedules.length) return true;
|
||||||
try {
|
try {
|
||||||
|
|
@ -151,9 +175,14 @@ PlaylistPlayer.prototype.playCurrent = function () {
|
||||||
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
|
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
|
||||||
if (!this.items.length) { this.idle(); return; }
|
if (!this.items.length) { this.idle(); return; }
|
||||||
|
|
||||||
|
this.itemStartedAt = Date.now(); // wall position fallback for non-video items
|
||||||
|
this.currentVideoEl = null; // set by renderVideo when applicable
|
||||||
|
|
||||||
var item = this.items[this.index];
|
var item = this.items[this.index];
|
||||||
// Scheduled playlists cycle even with one active item so windows re-evaluate.
|
// Scheduled playlists cycle even with one active item so windows re-evaluate.
|
||||||
var single = this.items.length === 1 && !this.anyScheduled();
|
// A wall FOLLOWER also behaves "single": it holds the leader's current item
|
||||||
|
// (looping, no auto-advance) and only switches when wall:sync says the index moved.
|
||||||
|
var single = this.wallFollower || (this.items.length === 1 && !this.anyScheduled());
|
||||||
var mime = item.mime_type || '';
|
var mime = item.mime_type || '';
|
||||||
this.clearStage();
|
this.clearStage();
|
||||||
|
|
||||||
|
|
@ -198,6 +227,7 @@ PlaylistPlayer.prototype.renderImage = function (item, single) {
|
||||||
PlaylistPlayer.prototype.renderVideo = function (item, single) {
|
PlaylistPlayer.prototype.renderVideo = function (item, single) {
|
||||||
var self = this;
|
var self = this;
|
||||||
var v = document.createElement('video');
|
var v = document.createElement('video');
|
||||||
|
this.currentVideoEl = v; // wall: leader reads currentTime; follower drift-corrects this
|
||||||
this.fit(v, item);
|
this.fit(v, item);
|
||||||
v.autoplay = true; v.muted = true; v.setAttribute('playsinline', '');
|
v.autoplay = true; v.muted = true; v.setAttribute('playsinline', '');
|
||||||
v.loop = single; // single item loops; multi advances on end
|
v.loop = single; // single item loops; multi advances on end
|
||||||
|
|
@ -244,3 +274,360 @@ PlaylistPlayer.prototype.youtubeId = function (url) {
|
||||||
if (/^[A-Za-z0-9_-]{11}$/.test(url)) return url; // bare id
|
if (/^[A-Za-z0-9_-]{11}$/.test(url)) return url; // bare id
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ZoneRenderer — multi-zone layout renderer for the Tizen player.
|
||||||
|
* Ports the Android player's ZoneManager (player/ZoneManager.kt). A layout is a set of
|
||||||
|
* absolutely-positioned zones (percent geometry + z-index + fit_mode + background), and
|
||||||
|
* EACH zone rotates its own list of assignments independently: images/widgets advance on
|
||||||
|
* a duration timer, videos advance on 'ended' (a single-item zone loops). The same
|
||||||
|
* per-item schedule gating (#74/#75) used in single-zone applies per zone. Assignments are
|
||||||
|
* grouped by zone_id and sorted by sort_order; unassigned content (zone_id null) goes to
|
||||||
|
* the FIRST zone only. Single-zone playback stays in PlaylistPlayer; app.js chooses the
|
||||||
|
* renderer from payload.layout.
|
||||||
|
*/
|
||||||
|
function ZoneRenderer(stageEl, getBase) {
|
||||||
|
this.stage = stageEl;
|
||||||
|
this.getBase = getBase;
|
||||||
|
this.timezone = null;
|
||||||
|
this.zones = [];
|
||||||
|
this.timers = {}; // zoneId -> timeout id
|
||||||
|
this.videos = {}; // zoneId -> <video> (pause before removal)
|
||||||
|
this.sig = '';
|
||||||
|
this.DEFAULT_DURATION = 10;
|
||||||
|
this.MIN_DURATION = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
ZoneRenderer.prototype.setTimezone = function (tz) { this.timezone = tz || null; };
|
||||||
|
ZoneRenderer.prototype.active = function () { return this.zones.length > 0; };
|
||||||
|
|
||||||
|
ZoneRenderer.prototype.cancelAll = function () {
|
||||||
|
for (var k in this.timers) { if (this.timers.hasOwnProperty(k) && this.timers[k]) clearTimeout(this.timers[k]); }
|
||||||
|
this.timers = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
ZoneRenderer.prototype.clear = function () {
|
||||||
|
this.cancelAll();
|
||||||
|
for (var k in this.videos) {
|
||||||
|
if (this.videos.hasOwnProperty(k) && this.videos[k]) {
|
||||||
|
try { this.videos[k].pause(); this.videos[k].removeAttribute('src'); this.videos[k].load(); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.videos = {};
|
||||||
|
this.zones = [];
|
||||||
|
this.sig = '';
|
||||||
|
this.stage.innerHTML = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
ZoneRenderer.prototype.signature = function (layout, assignments) {
|
||||||
|
var zsig = (layout.zones || []).map(function (z) {
|
||||||
|
return [z.id, z.x_percent, z.y_percent, z.width_percent, z.height_percent, z.z_index, z.fit_mode, z.background_color];
|
||||||
|
});
|
||||||
|
var asig = (assignments || []).map(function (a) {
|
||||||
|
return [a.zone_id || '', a.content_id, a.widget_id, a.remote_url, a.duration_sec, a.mime_type, a.sort_order, a.schedules || []];
|
||||||
|
});
|
||||||
|
return JSON.stringify([layout.id || '', zsig, asig]);
|
||||||
|
};
|
||||||
|
|
||||||
|
ZoneRenderer.prototype.render = function (layout, assignments) {
|
||||||
|
if (!layout || !layout.zones || !layout.zones.length) { this.clear(); return; }
|
||||||
|
var sig = this.signature(layout, assignments);
|
||||||
|
if (sig === this.sig && this.zones.length) return; // unchanged — keep zones playing
|
||||||
|
this.clear();
|
||||||
|
this.sig = sig;
|
||||||
|
|
||||||
|
// The stage must be a positioned containing block so zone % geometry resolves against
|
||||||
|
// it (applyOrientation leaves the stage static in landscape).
|
||||||
|
if (!this.stage.style.position) this.stage.style.position = 'relative';
|
||||||
|
|
||||||
|
this.zones = layout.zones.map(function (z) {
|
||||||
|
return {
|
||||||
|
id: z.id, name: z.name || 'Zone',
|
||||||
|
x: zrNum(z.x_percent, 0), y: zrNum(z.y_percent, 0),
|
||||||
|
w: zrNum(z.width_percent, 100), h: zrNum(z.height_percent, 100),
|
||||||
|
z: zrNum(z.z_index, 0),
|
||||||
|
fit: z.fit_mode || 'cover',
|
||||||
|
bg: z.background_color || '#000000'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group assignments by zone_id (sorted by sort_order); zone_id null -> first zone only.
|
||||||
|
var byZone = {}, unassigned = [];
|
||||||
|
(assignments || []).forEach(function (a) {
|
||||||
|
if (!a || !(a.content_id || a.widget_id || a.remote_url)) return;
|
||||||
|
if (a.zone_id == null || a.zone_id === '') unassigned.push(a);
|
||||||
|
else (byZone[a.zone_id] = byZone[a.zone_id] || []).push(a);
|
||||||
|
});
|
||||||
|
function bySort(a, b) { return (a.sort_order || 0) - (b.sort_order || 0); }
|
||||||
|
for (var zid in byZone) if (byZone.hasOwnProperty(zid)) byZone[zid].sort(bySort);
|
||||||
|
unassigned.sort(bySort);
|
||||||
|
|
||||||
|
var self = this, unassignedUsed = false;
|
||||||
|
this.zones.slice().sort(function (a, b) { return a.z - b.z; }).forEach(function (zone) {
|
||||||
|
var list = byZone[zone.id];
|
||||||
|
if (!list && !unassignedUsed) { unassignedUsed = true; list = unassigned; }
|
||||||
|
list = list || [];
|
||||||
|
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.style.position = 'absolute';
|
||||||
|
div.style.left = zone.x + '%'; div.style.top = zone.y + '%';
|
||||||
|
div.style.width = zone.w + '%'; div.style.height = zone.h + '%';
|
||||||
|
div.style.overflow = 'hidden';
|
||||||
|
div.style.zIndex = String(zone.z);
|
||||||
|
div.style.background = zone.bg;
|
||||||
|
self.stage.appendChild(div);
|
||||||
|
zone.el = div;
|
||||||
|
|
||||||
|
if (list.length) self.showItem(zone, list, 0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ZoneRenderer.prototype.scheduleAdvance = function (zone, ms, fn) {
|
||||||
|
if (this.timers[zone.id]) clearTimeout(this.timers[zone.id]);
|
||||||
|
this.timers[zone.id] = setTimeout(fn, ms);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-item schedule gating, mirrors PlaylistPlayer / Android. No blocks = always on;
|
||||||
|
// fails open (any evaluator error means the item plays).
|
||||||
|
ZoneRenderer.prototype.allows = function (item) {
|
||||||
|
if (!item || !item.schedules || !item.schedules.length) return true;
|
||||||
|
try {
|
||||||
|
return (typeof ScheduleEval !== 'undefined')
|
||||||
|
? ScheduleEval.isItemActiveNow(item.schedules, Date.now(), this.timezone) : true;
|
||||||
|
} catch (e) { return true; }
|
||||||
|
};
|
||||||
|
|
||||||
|
ZoneRenderer.prototype.nextActive = function (list, from) {
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
var idx = (from + i) % list.length;
|
||||||
|
if (this.allows(list[idx])) return idx;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
ZoneRenderer.prototype.durationMs = function (item) {
|
||||||
|
var d = item.duration_sec || this.DEFAULT_DURATION;
|
||||||
|
if (d < this.MIN_DURATION) d = this.MIN_DURATION;
|
||||||
|
return d * 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
ZoneRenderer.prototype.contentUrl = function (item) {
|
||||||
|
if (item.remote_url) return item.remote_url;
|
||||||
|
if (item.content_id) return this.getBase() + '/api/content/' + item.content_id + '/file';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
ZoneRenderer.prototype.showItem = function (zone, list, index) {
|
||||||
|
if (this.timers[zone.id]) { clearTimeout(this.timers[zone.id]); this.timers[zone.id] = null; }
|
||||||
|
if (this.videos[zone.id]) { try { this.videos[zone.id].pause(); } catch (e) {} this.videos[zone.id] = null; }
|
||||||
|
zone.el.innerHTML = '';
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
// #74/#75: skip items whose schedule excludes them now; blank-idle the zone and
|
||||||
|
// re-check shortly (a daypart may open) if none are active.
|
||||||
|
var activeIdx = this.nextActive(list, index);
|
||||||
|
if (activeIdx < 0) { this.scheduleAdvance(zone, 30000, function () { self.showItem(zone, list, 0); }); return; }
|
||||||
|
|
||||||
|
var a = list[activeIdx];
|
||||||
|
// Scheduled zones cycle even with one active item so windows re-evaluate.
|
||||||
|
var multi = list.length > 1 || list.some(function (x) { return x.schedules && x.schedules.length; });
|
||||||
|
var advance = function () { self.showItem(zone, list, activeIdx + 1); };
|
||||||
|
var dur = this.durationMs(a);
|
||||||
|
var mime = a.mime_type || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mime === 'video/youtube') {
|
||||||
|
var yid = zrYoutubeId(a.remote_url);
|
||||||
|
if (!yid) { if (multi) this.scheduleAdvance(zone, 2000, advance); return; }
|
||||||
|
var ysrc = 'https://www.youtube.com/embed/' + yid +
|
||||||
|
'?autoplay=1&mute=1&controls=0&rel=0&modestbranding=1&loop=1&playlist=' + yid + '&playsinline=1';
|
||||||
|
zone.el.appendChild(zrFrame(ysrc, 'autoplay; encrypted-media'));
|
||||||
|
if (multi) this.scheduleAdvance(zone, dur, advance);
|
||||||
|
} else if (a.widget_type || (a.widget_id && !a.content_id)) {
|
||||||
|
zone.el.appendChild(zrFrame(this.getBase() + '/api/widgets/' + a.widget_id + '/render'));
|
||||||
|
if (multi) this.scheduleAdvance(zone, dur, advance);
|
||||||
|
} else if (mime.indexOf('video/') === 0) {
|
||||||
|
var v = document.createElement('video');
|
||||||
|
v.className = zrFitClass(zone.fit);
|
||||||
|
// Zone videos are muted: TV web autoplay needs muted, and overlapping zone audio
|
||||||
|
// is rarely intended. (Single-zone fullscreen handles audio in PlaylistPlayer.)
|
||||||
|
v.autoplay = true; v.muted = true; v.setAttribute('playsinline', '');
|
||||||
|
v.loop = !multi; // single-item zone loops; multi advances on end
|
||||||
|
v.onended = function () { if (multi) advance(); };
|
||||||
|
v.onerror = function () { if (multi) self.scheduleAdvance(zone, 2000, advance); };
|
||||||
|
v.src = this.contentUrl(a);
|
||||||
|
zone.el.appendChild(v);
|
||||||
|
this.videos[zone.id] = v;
|
||||||
|
var p = v.play(); if (p && p.catch) p.catch(function () {});
|
||||||
|
if (multi) {
|
||||||
|
var secs = a.content_duration || a.duration_sec || this.DEFAULT_DURATION;
|
||||||
|
this.scheduleAdvance(zone, (secs + 5) * 1000, advance); // safety net if 'ended' never fires
|
||||||
|
}
|
||||||
|
} else if (mime.indexOf('image/') === 0) {
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.className = zrFitClass(zone.fit);
|
||||||
|
img.onerror = function () { if (multi) self.scheduleAdvance(zone, 2000, advance); };
|
||||||
|
img.src = this.contentUrl(a);
|
||||||
|
zone.el.appendChild(img);
|
||||||
|
if (multi) this.scheduleAdvance(zone, dur, advance);
|
||||||
|
} else if (a.remote_url) {
|
||||||
|
zone.el.appendChild(zrFrame(a.remote_url));
|
||||||
|
if (multi) this.scheduleAdvance(zone, dur, advance);
|
||||||
|
} else {
|
||||||
|
if (multi) this.scheduleAdvance(zone, dur, advance);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (multi) this.scheduleAdvance(zone, 2000, advance);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- ZoneRenderer helpers ---
|
||||||
|
function zrNum(v, d) { var n = parseFloat(v); return isNaN(n) ? d : n; }
|
||||||
|
function zrFitClass(fit) {
|
||||||
|
var f = String(fit || 'cover').toLowerCase();
|
||||||
|
if (f === 'contain' || f === 'fit') return 'contain';
|
||||||
|
if (f === 'fill' || f === 'stretch') return 'fill';
|
||||||
|
return 'cover';
|
||||||
|
}
|
||||||
|
function zrFrame(src, allow) {
|
||||||
|
var f = document.createElement('iframe');
|
||||||
|
f.setAttribute('frameborder', '0');
|
||||||
|
f.setAttribute('allowfullscreen', '');
|
||||||
|
if (allow) f.setAttribute('allow', allow);
|
||||||
|
f.src = src;
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
function zrYoutubeId(url) {
|
||||||
|
if (!url) return null;
|
||||||
|
var m = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/);
|
||||||
|
if (m) return m[1];
|
||||||
|
if (/^[A-Za-z0-9_-]{11}$/.test(url)) return url;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WallController — video-wall sync for the Tizen player.
|
||||||
|
* Mirrors the WEB player's wall logic (server/player/index.html); the Android player
|
||||||
|
* has no wall support, so the web player is the reference. A wall maps one playlist
|
||||||
|
* across several screens: each screen renders the FULL content (player_rect) but the
|
||||||
|
* stage is positioned (in vw/vh) so only this screen's slice (screen_rect) is on-view,
|
||||||
|
* with object-fit:fill so a given source row lands on the same physical line on every
|
||||||
|
* screen sharing a viewport height. The LEADER plays normally and broadcasts wall:sync
|
||||||
|
* at 4Hz; FOLLOWERS hold the leader's item (PlaylistPlayer.wallFollower) and keep their
|
||||||
|
* video locked to the leader's clock with a latency-compensated drift controller.
|
||||||
|
* (Tizen video is always muted, so the "followers stay silent" rule is automatic.)
|
||||||
|
*/
|
||||||
|
function WallController(stageEl, player, getSocket, getDeviceId, canEmit) {
|
||||||
|
this.stage = stageEl;
|
||||||
|
this.player = player;
|
||||||
|
this.getSocket = getSocket;
|
||||||
|
this.getDeviceId = getDeviceId;
|
||||||
|
this.canEmit = canEmit; // () -> authenticated && socket connected (don't emit pre-register)
|
||||||
|
this.config = null;
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
WallController.prototype.active = function () { return !!this.config; };
|
||||||
|
|
||||||
|
// Map this screen's slice. left/top/width/height in vw/vh so the viewport fills
|
||||||
|
// edge-to-edge (no pillarbox at the seam between adjacent screens).
|
||||||
|
WallController.prototype.styleStage = function (config) {
|
||||||
|
var s = config.screen_rect, p = config.player_rect;
|
||||||
|
if (!s || !p || !s.w || !s.h) return;
|
||||||
|
this.stage.classList.add('wall-mode');
|
||||||
|
var st = this.stage.style;
|
||||||
|
st.position = 'absolute';
|
||||||
|
st.left = (((p.x - s.x) / s.w) * 100) + 'vw';
|
||||||
|
st.top = (((p.y - s.y) / s.h) * 100) + 'vh';
|
||||||
|
st.width = ((p.w / s.w) * 100) + 'vw';
|
||||||
|
st.height = ((p.h / s.h) * 100) + 'vh';
|
||||||
|
st.transform = ''; st.transformOrigin = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
WallController.prototype.apply = function (config) {
|
||||||
|
var roleChanged = !this.config ||
|
||||||
|
this.config.is_leader !== config.is_leader ||
|
||||||
|
this.config.wall_id !== config.wall_id;
|
||||||
|
this.config = config;
|
||||||
|
|
||||||
|
this.styleStage(config);
|
||||||
|
this.player.setWallFollower(!config.is_leader);
|
||||||
|
// Entering wall mode or flipping role: force a fresh render so leader/follower
|
||||||
|
// semantics take effect (otherwise an unchanged signature de-dupes the load).
|
||||||
|
if (roleChanged) this.player.invalidate();
|
||||||
|
|
||||||
|
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
||||||
|
var self = this;
|
||||||
|
if (config.is_leader) {
|
||||||
|
// 4Hz so followers nudge playbackRate instead of jerk-seeking; immediate first
|
||||||
|
// tick so any already-up follower aligns now (and on leader-reclaim after reconnect).
|
||||||
|
this.timer = setInterval(function () { self.emitSync(); }, 250);
|
||||||
|
setTimeout(function () { self.emitSync(); }, 100);
|
||||||
|
} else {
|
||||||
|
// Follower: ask the leader for its position now so we don't show the item start
|
||||||
|
// until the next periodic tick (up to ~250ms of visible drift on a fresh join).
|
||||||
|
var s = this.getSocket();
|
||||||
|
if (s && this.canEmit()) s.emit('wall:sync-request', { wall_id: config.wall_id });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
WallController.prototype.exit = function () {
|
||||||
|
var wasActive = !!this.config || !!this.timer || this.stage.classList.contains('wall-mode');
|
||||||
|
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
||||||
|
this.config = null;
|
||||||
|
this.player.setWallFollower(false);
|
||||||
|
if (wasActive) {
|
||||||
|
this.stage.classList.remove('wall-mode');
|
||||||
|
var st = this.stage.style;
|
||||||
|
st.position = ''; st.left = ''; st.top = ''; st.width = ''; st.height = '';
|
||||||
|
st.transform = ''; st.transformOrigin = '';
|
||||||
|
this.player.invalidate(); // re-render cleanly back into normal (non-wall) mode
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
WallController.prototype.emitSync = function () {
|
||||||
|
if (!this.config || !this.config.is_leader || !this.canEmit()) return;
|
||||||
|
var s = this.getSocket(); if (!s) return;
|
||||||
|
var item = this.player.getCurrentItem();
|
||||||
|
if (!item) return;
|
||||||
|
var v = this.player.getCurrentVideo();
|
||||||
|
var pos = v ? (v.currentTime || 0)
|
||||||
|
: Math.max(0, (Date.now() - this.player.getItemStartedAt()) / 1000);
|
||||||
|
s.emit('wall:sync', {
|
||||||
|
wall_id: this.config.wall_id,
|
||||||
|
device_id: this.getDeviceId(),
|
||||||
|
current_index: this.player.getIndex(),
|
||||||
|
content_id: item.content_id || null,
|
||||||
|
position_sec: pos,
|
||||||
|
sent_at: Date.now()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
WallController.prototype.onSync = function (data) {
|
||||||
|
var c = this.config;
|
||||||
|
if (!c || c.is_leader || !data || data.wall_id !== c.wall_id) return;
|
||||||
|
// Align to the leader's current item.
|
||||||
|
if (typeof data.current_index === 'number' && data.current_index !== this.player.getIndex()) {
|
||||||
|
this.player.gotoIndex(data.current_index);
|
||||||
|
}
|
||||||
|
// Hold close to the leader's clock, latency-compensated (mirrors the web player):
|
||||||
|
// > 0.3s -> hard seek + reset rate
|
||||||
|
// > 0.05s -> nudge playbackRate +/-3% to converge gently
|
||||||
|
// else -> ride at 1.0x
|
||||||
|
var v = this.player.getCurrentVideo();
|
||||||
|
if (v && typeof data.position_sec === 'number') {
|
||||||
|
var latency = data.sent_at ? Math.max(0, (Date.now() - data.sent_at) / 1000) : 0;
|
||||||
|
var target = data.position_sec + latency;
|
||||||
|
var drift = (v.currentTime || 0) - target;
|
||||||
|
var ad = Math.abs(drift);
|
||||||
|
try {
|
||||||
|
if (ad > 0.3 && isFinite(v.duration) && target < v.duration) { v.currentTime = target; v.playbackRate = 1.0; }
|
||||||
|
else if (ad > 0.05) { v.playbackRate = drift > 0 ? 0.97 : 1.03; }
|
||||||
|
else if (v.playbackRate !== 1.0) { v.playbackRate = 1.0; }
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
WallController.prototype.onSyncRequest = function (data) {
|
||||||
|
if (!this.config || !this.config.is_leader) return;
|
||||||
|
if (data && data.wall_id && data.wall_id !== this.config.wall_id) return;
|
||||||
|
this.emitSync();
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue