mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
Merge pull request #69 from screentinker/feat/tizen-player
feat(tizen): Samsung Tizen TV web player (.wgt)
This commit is contained in:
commit
6bcd193e45
7
tizen/.gitignore
vendored
Normal file
7
tizen/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Build artifacts (regenerated by build-wgt.sh)
|
||||
ScreenTinker.wgt
|
||||
*.wgt
|
||||
# Test / scratch files
|
||||
_*
|
||||
.buildResult
|
||||
.manifest.tmp
|
||||
70
tizen/README.md
Normal file
70
tizen/README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# ScreenTinker — Tizen TV Player (`.wgt`)
|
||||
|
||||
A Samsung **Tizen TV / signage** web port of the ScreenTinker player. It speaks the
|
||||
**exact same `/device` socket.io protocol** as the Android player, so a Tizen
|
||||
display pairs and plays from the same dashboard with no server changes.
|
||||
|
||||
## What it does
|
||||
- Enter a server URL → connects to `{server}/device` (socket.io v4).
|
||||
- Registers, shows a **6-digit pairing code**; you claim it in the dashboard
|
||||
(Devices → Pair a display). On `device:paired` it switches to playback.
|
||||
- Reconnects automatically with a stored `device_id` + `device_token`.
|
||||
- Renders **fullscreen single-zone** playlists, looping:
|
||||
- **image** → shown for `duration_sec` (min 3s)
|
||||
- **video** (`/api/content/{id}/file` or `remote_url`) → plays to end, then next; single item loops
|
||||
- **YouTube** (`mime video/youtube`) → muted autoplay `<iframe>` embed
|
||||
- **widget** → `<iframe>` of `{server}/api/widgets/{id}/render`
|
||||
- Sends `device:heartbeat` every 15s (with best-effort Tizen telemetry).
|
||||
- Keeps the screen awake (`tizen.power` / Samsung `appcommon` screensaver-off).
|
||||
|
||||
## Files
|
||||
```
|
||||
config.xml Tizen TV web-app manifest (privileges, profile, icon)
|
||||
index.html setup / pairing / stage screens
|
||||
css/style.css
|
||||
js/app.js device protocol client (register, pair, heartbeat, state)
|
||||
js/player.js fullscreen playlist renderer
|
||||
js/socket.io.min.js socket.io-client v4.7.5 (bundled)
|
||||
icon.png
|
||||
build-wgt.sh package (signed if Tizen CLI present, else unsigned)
|
||||
```
|
||||
|
||||
## Build
|
||||
```bash
|
||||
./build-wgt.sh # -> ScreenTinker.wgt
|
||||
```
|
||||
Without the Tizen CLI this is an **unsigned** `.wgt`.
|
||||
|
||||
## Deploy — two paths
|
||||
|
||||
### A) URL Launcher (easiest, no signing) — Samsung signage (SSSP)
|
||||
No package needed. Host this folder on any web server (e.g. the ScreenTinker
|
||||
server itself) and point the display's **URL Launcher** at `…/index.html`.
|
||||
The TV runs it as a web app on boot. Best for Samsung B2B signage displays.
|
||||
|
||||
### B) Signed `.wgt` (retail TVs / installed app)
|
||||
Retail Tizen TVs require a Samsung-signed package:
|
||||
1. Install **Tizen Studio** + the TV extension.
|
||||
2. **Certificate Manager** → create a Samsung author + distributor certificate
|
||||
(needs a free Samsung account; distributor cert must include the TV's **DUID**).
|
||||
3. Create a signing **profile**, then:
|
||||
```bash
|
||||
./build-wgt.sh <profileName> # uses `tizen package -t wgt -s <profileName>`
|
||||
```
|
||||
4. Put the TV in **Developer Mode** (Apps → 12345 → enter host IP), then install:
|
||||
```bash
|
||||
sdb connect <tv-ip>
|
||||
tizen install -n ScreenTinker.wgt -t <tv-device>
|
||||
```
|
||||
|
||||
## Validated (2026-06-09)
|
||||
- **Protocol**: headless test against the live server passed end-to-end —
|
||||
`register(pairing_code) → device:registered → pair → reconnect(device_id+token)
|
||||
→ device:playlist-update(2 items) → GET /api/content/{id}/file = 200`.
|
||||
- **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 ported (Android player has these; fullscreen single-zone covers most signage)
|
||||
Multi-zone layouts, video walls (`wall:sync`), screenshots, remote touch/control,
|
||||
and self-OTA (Tizen apps update via Samsung's store / URL Launcher refresh, not the
|
||||
Android `PackageInstaller` flow).
|
||||
22
tizen/build-wgt.sh
Executable file
22
tizen/build-wgt.sh
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
#!/bin/bash
|
||||
# Build the ScreenTinker Tizen .wgt.
|
||||
# - If the Tizen CLI is on PATH, sign with a security profile (arg 1, default
|
||||
# "ScreenTinker"): produces a TV-installable signed .wgt.
|
||||
# - Otherwise, produce an UNSIGNED .wgt (plain zip) — fine for inspection / the
|
||||
# URL-Launcher path, but retail Samsung TVs need a signed package.
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
OUT="ScreenTinker.wgt"
|
||||
FILES="config.xml index.html icon.png css js"
|
||||
rm -f "$OUT"
|
||||
|
||||
if command -v tizen >/dev/null 2>&1; then
|
||||
PROFILE="${1:-ScreenTinker}"
|
||||
echo "Tizen CLI found — signing with profile '$PROFILE'…"
|
||||
tizen package -t wgt -s "$PROFILE" -- . -o .
|
||||
echo "Signed $OUT ready."
|
||||
else
|
||||
echo "Tizen CLI not found — building UNSIGNED $OUT."
|
||||
zip -r -X "$OUT" $FILES -x '*.DS_Store' '_*' >/dev/null
|
||||
echo "Built $OUT ($(du -h "$OUT" | cut -f1), UNSIGNED — sign before installing on a retail TV)."
|
||||
fi
|
||||
27
tizen/config.xml
Normal file
27
tizen/config.xml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets"
|
||||
id="http://screentinker.com/player" version="1.0.0" viewmodes="maximized">
|
||||
<tizen:application id="ScrnTinkr1.ScreenTinker" package="ScrnTinkr1" required_version="2.4"/>
|
||||
<tizen:profile name="tv"/>
|
||||
<name>ScreenTinker</name>
|
||||
<author email="dw5304@gmail.com">ScreenTinker</author>
|
||||
<description>ScreenTinker digital signage player</description>
|
||||
<icon src="icon.png"/>
|
||||
<content src="index.html"/>
|
||||
|
||||
<!-- Landscape signage, no context menu, allow background, keep app full-screen -->
|
||||
<tizen:setting screen-orientation="landscape" context-menu="disable"
|
||||
background-support="enable" encryption="disable"
|
||||
install-location="auto" hwkey-event="enable"/>
|
||||
|
||||
<feature name="http://tizen.org/feature/screen.size.all"/>
|
||||
|
||||
<!-- Allow the player to reach any signage server + load remote media / YouTube -->
|
||||
<access origin="*" subdomains="true"/>
|
||||
<tizen:allow-navigation>*</tizen:allow-navigation>
|
||||
|
||||
<tizen:privilege name="http://tizen.org/privilege/internet"/>
|
||||
<tizen:privilege name="http://tizen.org/privilege/application.launch"/>
|
||||
<tizen:privilege name="http://tizen.org/privilege/display"/>
|
||||
<tizen:privilege name="http://developer.samsung.com/privilege/network.public"/>
|
||||
</widget>
|
||||
72
tizen/css/style.css
Normal file
72
tizen/css/style.css
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: #000; color: #f1f5f9;
|
||||
font-family: "Samsung One", "Tizen Sans", Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.screen {
|
||||
position: absolute; top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Setup / pairing card */
|
||||
.card {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 18px;
|
||||
padding: 48px 64px;
|
||||
text-align: center;
|
||||
max-width: 760px;
|
||||
}
|
||||
.card h1 { color: #3b82f6; font-size: 44px; margin-bottom: 6px; }
|
||||
.sub { color: #94a3b8; font-size: 22px; margin-bottom: 36px; }
|
||||
.card label { display: block; text-align: left; color: #94a3b8; font-size: 18px; margin-bottom: 8px; }
|
||||
|
||||
#serverUrl {
|
||||
width: 100%; font-size: 26px; padding: 16px 20px;
|
||||
border-radius: 10px; border: 2px solid #334155;
|
||||
background: #0b1220; color: #f1f5f9; margin-bottom: 24px;
|
||||
}
|
||||
#serverUrl:focus { outline: none; border-color: #3b82f6; }
|
||||
|
||||
button {
|
||||
font-size: 24px; font-weight: bold; color: #fff;
|
||||
background: #3b82f6; border: none; border-radius: 10px;
|
||||
padding: 16px 40px; cursor: pointer;
|
||||
}
|
||||
button:focus { outline: 3px solid #93c5fd; }
|
||||
button.ghost { background: transparent; color: #64748b; font-size: 18px; margin-top: 24px; padding: 8px; }
|
||||
|
||||
.status { color: #64748b; font-size: 18px; margin-top: 20px; min-height: 24px; }
|
||||
.status.error { color: #ef4444; }
|
||||
|
||||
/* Pairing code */
|
||||
.code {
|
||||
font-size: 96px; font-weight: bold; letter-spacing: 16px;
|
||||
color: #22c55e; margin: 24px 0; font-family: monospace;
|
||||
}
|
||||
.hint { color: #94a3b8; font-size: 20px; line-height: 1.5; }
|
||||
|
||||
/* Playback stage */
|
||||
.stage { background: #000; }
|
||||
.stage img, .stage video, .stage iframe {
|
||||
position: absolute; top: 0; left: 0;
|
||||
width: 100%; height: 100%; border: 0;
|
||||
}
|
||||
.stage img.contain, .stage video.contain { object-fit: contain; }
|
||||
.stage img.cover, .stage video.cover { object-fit: cover; }
|
||||
.stage img.fill, .stage video.fill { object-fit: fill; }
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
background: rgba(17,24,39,0.92); color: #f1f5f9;
|
||||
padding: 12px 24px; border-radius: 10px; font-size: 18px;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
BIN
tizen/icon.png
Normal file
BIN
tizen/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
45
tizen/index.html
Normal file
45
tizen/index.html
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title>ScreenTinker</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Setup: enter server URL -->
|
||||
<div id="setup" class="screen">
|
||||
<div class="card">
|
||||
<h1>ScreenTinker</h1>
|
||||
<p class="sub">Digital Signage Player</p>
|
||||
<label for="serverUrl">Server URL</label>
|
||||
<input id="serverUrl" type="url" value="https://screentinker.com" autocomplete="off"
|
||||
autocorrect="off" autocapitalize="off" spellcheck="false">
|
||||
<button id="connectBtn">Connect</button>
|
||||
<p id="setupStatus" class="status"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pairing: show the code -->
|
||||
<div id="pairing" class="screen hidden">
|
||||
<div class="card">
|
||||
<h1>ScreenTinker</h1>
|
||||
<p class="sub">Pair this display</p>
|
||||
<div id="pairCode" class="code">------</div>
|
||||
<p class="hint">Enter this code in your ScreenTinker dashboard<br>(Devices → Pair a display)</p>
|
||||
<p id="pairStatus" class="status">Waiting to be paired…</p>
|
||||
<button id="resetBtn" class="ghost">Change server</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playback stage -->
|
||||
<div id="stage" class="screen stage hidden"></div>
|
||||
|
||||
<!-- Tiny on-screen status (offline / errors), auto-hides -->
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<script src="js/socket.io.min.js"></script>
|
||||
<script src="js/player.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
266
tizen/js/app.js
Normal file
266
tizen/js/app.js
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
/* ScreenTinker — Tizen TV web player.
|
||||
* Speaks the same /device socket.io protocol as the Android player:
|
||||
* emit device:register {pairing_code | device_id+device_token, device_info, fingerprint}
|
||||
* recv device:registered {device_id, device_token, status}
|
||||
* recv device:paired {name} -> go to playback
|
||||
* recv device:unpaired {reason} -> clear creds, re-provision
|
||||
* recv device:auth-error {error}
|
||||
* recv device:playlist-update {assignments, layout, orientation, suspended?, message?, detail?}
|
||||
* emit device:heartbeat {device_id, telemetry} every 15s
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var APP_VERSION = '1.0.0';
|
||||
var HEARTBEAT_MS = 15000;
|
||||
var DEFAULT_DURATION = 10;
|
||||
var MIN_DURATION = 3;
|
||||
|
||||
var LS = {
|
||||
url: 'st_server_url',
|
||||
id: 'st_device_id',
|
||||
token: 'st_device_token',
|
||||
fp: 'st_fingerprint',
|
||||
code: 'st_pairing_code'
|
||||
};
|
||||
|
||||
// ---- persistent state ----
|
||||
function get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
|
||||
function set(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
|
||||
function del(k) { try { localStorage.removeItem(k); } catch (e) {} }
|
||||
|
||||
function uuid() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
var r = (Math.random() * 16) | 0;
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
}
|
||||
function fingerprint() {
|
||||
var fp = get(LS.fp);
|
||||
if (!fp) { fp = uuid().replace(/-/g, ''); set(LS.fp, fp); }
|
||||
return fp;
|
||||
}
|
||||
function pairingCode() {
|
||||
var c = get(LS.code);
|
||||
if (!c) { c = String(Math.floor(100000 + Math.random() * 900000)); set(LS.code, c); }
|
||||
return c;
|
||||
}
|
||||
|
||||
// ---- DOM ----
|
||||
var elSetup = document.getElementById('setup');
|
||||
var elPairing = document.getElementById('pairing');
|
||||
var elStage = document.getElementById('stage');
|
||||
var elUrl = document.getElementById('serverUrl');
|
||||
var elConnect = document.getElementById('connectBtn');
|
||||
var elSetupStatus = document.getElementById('setupStatus');
|
||||
var elPairCode = document.getElementById('pairCode');
|
||||
var elPairStatus = document.getElementById('pairStatus');
|
||||
var elReset = document.getElementById('resetBtn');
|
||||
var elToast = document.getElementById('toast');
|
||||
|
||||
function show(el) { [elSetup, elPairing, elStage].forEach(function (e) { e.classList.add('hidden'); }); el.classList.remove('hidden'); }
|
||||
var toastTimer = null;
|
||||
function toast(msg, sticky) {
|
||||
elToast.textContent = msg; elToast.classList.remove('hidden');
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
if (!sticky) toastTimer = setTimeout(function () { elToast.classList.add('hidden'); }, 4000);
|
||||
}
|
||||
function clearToast() { if (toastTimer) clearTimeout(toastTimer); elToast.classList.add('hidden'); }
|
||||
|
||||
// Keep the screen awake (best effort across Tizen APIs)
|
||||
function keepAwake() {
|
||||
try { if (window.tizen && tizen.power) tizen.power.request('SCREEN', 'SCREEN_NORMAL'); } catch (e) {}
|
||||
try { if (window.webapis && webapis.appcommon) webapis.appcommon.setScreenSaver(webapis.appcommon.AppCommonScreenSaverState.SCREEN_SAVER_OFF); } catch (e) {}
|
||||
}
|
||||
|
||||
// ---- networking ----
|
||||
var socket = null;
|
||||
var deviceId = get(LS.id);
|
||||
var deviceToken = get(LS.token);
|
||||
var serverUrl = get(LS.url);
|
||||
var heartbeatTimer = null;
|
||||
var beatCount = 0;
|
||||
|
||||
function deviceInfo() {
|
||||
return {
|
||||
android_version: 'Tizen ' + (tizenVersion() || ''),
|
||||
app_version: APP_VERSION,
|
||||
screen_width: window.screen ? screen.width : window.innerWidth,
|
||||
screen_height: window.screen ? screen.height : window.innerHeight
|
||||
};
|
||||
}
|
||||
function tizenVersion() {
|
||||
try { return tizen.systeminfo.getCapability('http://tizen.org/feature/platform.version'); } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function telemetry() {
|
||||
var t = { uptime_seconds: Math.floor(performance.now() / 1000) };
|
||||
try {
|
||||
tizen.systeminfo.getPropertyValue('BATTERY', function (b) {
|
||||
t.battery_level = Math.round((b.level || 0) * 100);
|
||||
t.battery_charging = !!b.isCharging;
|
||||
});
|
||||
} catch (e) {}
|
||||
return t;
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!serverUrl) { show(elSetup); return; }
|
||||
keepAwake();
|
||||
if (socket) { try { socket.disconnect(); } catch (e) {} socket = null; }
|
||||
|
||||
var base = serverUrl.replace(/\/+$/, '');
|
||||
socket = io(base + '/device', {
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnection: true,
|
||||
reconnectionDelay: 2000,
|
||||
reconnectionDelayMax: 10000,
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
socket.on('connect', function () {
|
||||
clearToast();
|
||||
register();
|
||||
});
|
||||
socket.on('connect_error', function (err) {
|
||||
if (!deviceId) {
|
||||
// Not provisioned yet — fall back to the server prompt so a bad/unreachable
|
||||
// URL can be corrected instead of leaving a blank screen.
|
||||
elUrl.value = serverUrl || '';
|
||||
elSetupStatus.textContent = 'Could not reach server: ' + (err && err.message ? err.message : 'error');
|
||||
elSetupStatus.className = 'status error';
|
||||
show(elSetup); elUrl.focus();
|
||||
} else {
|
||||
toast('Reconnecting…', true);
|
||||
}
|
||||
});
|
||||
socket.on('disconnect', function () { toast('Reconnecting…', true); });
|
||||
|
||||
socket.on('device:registered', function (data) {
|
||||
deviceId = data.device_id; deviceToken = data.device_token;
|
||||
set(LS.id, deviceId); set(LS.token, deviceToken);
|
||||
startHeartbeat();
|
||||
if (data.status === 'provisioning') showPairing();
|
||||
});
|
||||
|
||||
socket.on('device:paired', function () {
|
||||
del(LS.code); clearToast(); show(elStage);
|
||||
});
|
||||
|
||||
socket.on('device:unpaired', function () {
|
||||
del(LS.id); del(LS.token); del(LS.code);
|
||||
deviceId = null; deviceToken = null;
|
||||
register(); // re-register fresh -> new pairing code
|
||||
});
|
||||
|
||||
socket.on('device:auth-error', function (data) {
|
||||
// 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);
|
||||
deviceId = null; deviceToken = null;
|
||||
setTimeout(register, 3000);
|
||||
});
|
||||
|
||||
socket.on('device:playlist-update', onPlaylist);
|
||||
|
||||
// Optional remote commands the dashboard may send (best-effort)
|
||||
socket.on('device:reload', function () { location.reload(); });
|
||||
}
|
||||
|
||||
function register() {
|
||||
var msg = { device_info: deviceInfo(), fingerprint: fingerprint() };
|
||||
if (deviceId && deviceToken) { msg.device_id = deviceId; msg.device_token = deviceToken; }
|
||||
else { msg.pairing_code = pairingCode(); }
|
||||
socket.emit('device:register', msg);
|
||||
}
|
||||
|
||||
function showPairing() {
|
||||
elPairCode.textContent = pairingCode();
|
||||
show(elPairing);
|
||||
}
|
||||
|
||||
function startHeartbeat() {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = setInterval(function () {
|
||||
if (!socket || !deviceId) return;
|
||||
socket.emit('device:heartbeat', { device_id: deviceId, telemetry: telemetry() });
|
||||
// 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() });
|
||||
}, HEARTBEAT_MS);
|
||||
}
|
||||
|
||||
// ---- playback ----
|
||||
var player = new PlaylistPlayer(elStage, function () { return serverUrl.replace(/\/+$/, ''); });
|
||||
|
||||
function onPlaylist(payload) {
|
||||
if (!payload) return;
|
||||
if (payload.suspended) {
|
||||
player.stop();
|
||||
elStage.innerHTML = '<div class="card" style="position:relative"><h1>' +
|
||||
esc(payload.message || 'Display suspended') + '</h1><p class="sub">' +
|
||||
esc(payload.detail || '') + '</p></div>';
|
||||
show(elStage);
|
||||
return;
|
||||
}
|
||||
// If we have content + we're paired, make sure we're on the stage.
|
||||
if (elPairing.classList.contains('hidden') === false) show(elStage);
|
||||
else if (elStage.classList.contains('hidden')) show(elStage);
|
||||
player.load(payload.assignments || []);
|
||||
}
|
||||
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]; }); }
|
||||
|
||||
// ---- setup screen wiring ----
|
||||
if (serverUrl) elUrl.value = serverUrl;
|
||||
elConnect.addEventListener('click', doConnect);
|
||||
elUrl.addEventListener('keydown', function (e) { if (e.keyCode === 13) doConnect(); });
|
||||
function doConnect() {
|
||||
var v = (elUrl.value || '').trim();
|
||||
if (!v) { elSetupStatus.textContent = 'Enter a server URL'; return; }
|
||||
if (!/^https?:\/\//i.test(v)) v = 'https://' + v;
|
||||
serverUrl = v; set(LS.url, serverUrl);
|
||||
elSetupStatus.className = 'status';
|
||||
elSetupStatus.textContent = 'Connecting…';
|
||||
connect();
|
||||
}
|
||||
elReset.addEventListener('click', function () {
|
||||
del(LS.url); del(LS.id); del(LS.token); del(LS.code);
|
||||
deviceId = null; deviceToken = null; serverUrl = null;
|
||||
if (socket) { try { socket.disconnect(); } catch (e) {} }
|
||||
show(elSetup);
|
||||
});
|
||||
|
||||
// TV remote BACK key (10009): from the stage/pairing screen, return to the
|
||||
// server prompt so the operator can always change the server; from setup, exit.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.keyCode === 10009) { // Samsung RETURN / BACK
|
||||
if (!elSetup.classList.contains('hidden')) {
|
||||
try { tizen.application.getCurrentApplication().exit(); } catch (x) {}
|
||||
} else {
|
||||
if (socket) { try { socket.disconnect(); } catch (x) {} }
|
||||
elUrl.value = serverUrl || '';
|
||||
elSetupStatus.textContent = ''; elSetupStatus.className = 'status';
|
||||
show(elSetup); elUrl.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---- boot ----
|
||||
// Always reach the server prompt until the display is actually paired. Only a
|
||||
// fully provisioned device (has a saved device_id + token) goes straight to
|
||||
// playback; otherwise show the setup screen and ask for / confirm the server.
|
||||
keepAwake();
|
||||
if (serverUrl && deviceId && deviceToken) {
|
||||
show(elStage); connect(); // paired — reconnect to playback
|
||||
} else if (serverUrl) {
|
||||
show(elSetup); elUrl.value = serverUrl; // server known, not paired — confirm + connect
|
||||
elSetupStatus.className = 'status';
|
||||
elSetupStatus.textContent = 'Connecting…';
|
||||
connect();
|
||||
} else {
|
||||
show(elSetup); elUrl.focus(); // first run — ask for the server
|
||||
}
|
||||
|
||||
// Expose for debugging
|
||||
window.__st = { connect: connect, reset: function () { elReset.click(); } };
|
||||
})();
|
||||
177
tizen/js/player.js
Normal file
177
tizen/js/player.js
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/* PlaylistPlayer — fullscreen single-zone renderer for the Tizen player.
|
||||
* Mirrors the Android player's content rules:
|
||||
* image -> shown for duration_sec (min 3s), then advance
|
||||
* video -> plays to end then advance; single item loops
|
||||
* video/youtube-> iframe embed; single item loops, multi advances after duration
|
||||
* remote_url -> same as image/video but src = remote_url
|
||||
* widget -> iframe of {server}/api/widgets/{id}/render for duration_sec
|
||||
* Content file URL: {server}/api/content/{content_id}/file (public)
|
||||
*/
|
||||
function PlaylistPlayer(stageEl, getBase) {
|
||||
this.stage = stageEl;
|
||||
this.getBase = getBase;
|
||||
this.items = [];
|
||||
this.index = 0;
|
||||
this.timer = null;
|
||||
this.sig = '';
|
||||
this.DEFAULT_DURATION = 10;
|
||||
this.MIN_DURATION = 3;
|
||||
}
|
||||
|
||||
PlaylistPlayer.prototype.load = function (assignments) {
|
||||
var items = (assignments || []).filter(function (a) {
|
||||
return a && (a.content_id || a.widget_id || a.remote_url);
|
||||
});
|
||||
// Stable order
|
||||
items.sort(function (a, b) { return (a.sort_order || 0) - (b.sort_order || 0); });
|
||||
|
||||
var sig = JSON.stringify(items.map(function (a) {
|
||||
return [a.content_id, a.widget_id, a.remote_url, a.duration_sec, a.mime_type];
|
||||
}));
|
||||
if (sig === this.sig && this.items.length) return; // unchanged, keep playing
|
||||
|
||||
this.sig = sig;
|
||||
this.items = items;
|
||||
this.index = 0;
|
||||
this.playCurrent();
|
||||
};
|
||||
|
||||
PlaylistPlayer.prototype.stop = function () {
|
||||
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
|
||||
this.clearStage();
|
||||
};
|
||||
|
||||
PlaylistPlayer.prototype.clearStage = function () {
|
||||
// Pause any video before removing so audio doesn't linger.
|
||||
var v = this.stage.querySelector('video');
|
||||
if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch (e) {} }
|
||||
this.stage.innerHTML = '';
|
||||
};
|
||||
|
||||
PlaylistPlayer.prototype.idle = function () {
|
||||
this.clearStage();
|
||||
this.stage.innerHTML =
|
||||
'<div class="card" style="position:relative"><h1>ScreenTinker</h1>' +
|
||||
'<p class="sub">No content assigned yet</p></div>';
|
||||
};
|
||||
|
||||
PlaylistPlayer.prototype.durationMs = function (item) {
|
||||
var d = item.duration_sec || this.DEFAULT_DURATION;
|
||||
if (d < this.MIN_DURATION) d = this.MIN_DURATION;
|
||||
return d * 1000;
|
||||
};
|
||||
|
||||
PlaylistPlayer.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;
|
||||
};
|
||||
|
||||
PlaylistPlayer.prototype.advance = function () {
|
||||
if (!this.items.length) return;
|
||||
this.index = (this.index + 1) % this.items.length;
|
||||
this.playCurrent();
|
||||
};
|
||||
|
||||
PlaylistPlayer.prototype.schedule = function (ms) {
|
||||
var self = this;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(function () { self.advance(); }, ms);
|
||||
};
|
||||
|
||||
PlaylistPlayer.prototype.playCurrent = function () {
|
||||
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
|
||||
if (!this.items.length) { this.idle(); return; }
|
||||
|
||||
var item = this.items[this.index];
|
||||
var single = this.items.length === 1;
|
||||
var mime = item.mime_type || '';
|
||||
this.clearStage();
|
||||
|
||||
try {
|
||||
if (mime === 'video/youtube') return this.renderYouTube(item, single);
|
||||
if (item.widget_id && !item.content_id) return this.renderWidget(item, single);
|
||||
if (mime.indexOf('video/') === 0) return this.renderVideo(item, single);
|
||||
if (mime.indexOf('image/') === 0) return this.renderImage(item, single);
|
||||
// Fallback: a remote_url with unknown mime -> try iframe
|
||||
if (item.remote_url) return this.renderFrame(item.remote_url, single ? 0 : this.durationMs(item));
|
||||
} catch (e) {
|
||||
this.skipSoon();
|
||||
return;
|
||||
}
|
||||
// Unknown item -> skip
|
||||
this.skipSoon();
|
||||
};
|
||||
|
||||
// Give a broken item ~2s then move on so the loop never wedges.
|
||||
PlaylistPlayer.prototype.skipSoon = function () {
|
||||
if (this.items.length > 1) this.schedule(2000);
|
||||
};
|
||||
|
||||
PlaylistPlayer.prototype.fit = function (el, item) {
|
||||
// assignment may carry a fit hint; default cover (matches Android default)
|
||||
var f = (item.fit || item.scale || 'cover').toLowerCase();
|
||||
if (f === 'contain' || f === 'fit') el.className = 'contain';
|
||||
else if (f === 'fill' || f === 'stretch') el.className = 'fill';
|
||||
else el.className = 'cover';
|
||||
};
|
||||
|
||||
PlaylistPlayer.prototype.renderImage = function (item, single) {
|
||||
var self = this;
|
||||
var img = document.createElement('img');
|
||||
this.fit(img, item);
|
||||
img.onerror = function () { self.skipSoon(); };
|
||||
img.src = this.contentUrl(item);
|
||||
this.stage.appendChild(img);
|
||||
if (!single) this.schedule(this.durationMs(item));
|
||||
};
|
||||
|
||||
PlaylistPlayer.prototype.renderVideo = function (item, single) {
|
||||
var self = this;
|
||||
var v = document.createElement('video');
|
||||
this.fit(v, item);
|
||||
v.autoplay = true; v.muted = true; v.setAttribute('playsinline', '');
|
||||
v.loop = single; // single item loops; multi advances on end
|
||||
v.onended = function () { if (!single) self.advance(); };
|
||||
v.onerror = function () { self.skipSoon(); };
|
||||
v.src = this.contentUrl(item);
|
||||
this.stage.appendChild(v);
|
||||
var p = v.play(); if (p && p.catch) p.catch(function () {});
|
||||
// Safety net: if 'ended' never fires (rare), advance after the known
|
||||
// content duration (or the assignment duration) + a buffer.
|
||||
if (!single) {
|
||||
var secs = item.content_duration || item.duration_sec || this.DEFAULT_DURATION;
|
||||
this.schedule((secs + 5) * 1000);
|
||||
}
|
||||
};
|
||||
|
||||
PlaylistPlayer.prototype.renderYouTube = function (item, single) {
|
||||
var id = this.youtubeId(item.remote_url);
|
||||
if (!id) { this.skipSoon(); return; }
|
||||
var src = 'https://www.youtube.com/embed/' + id +
|
||||
'?autoplay=1&mute=1&controls=0&rel=0&modestbranding=1&loop=1&playlist=' + id + '&playsinline=1';
|
||||
this.renderFrame(src, single ? 0 : this.durationMs(item), 'autoplay; encrypted-media');
|
||||
};
|
||||
|
||||
PlaylistPlayer.prototype.renderWidget = function (item, single) {
|
||||
var src = this.getBase() + '/api/widgets/' + item.widget_id + '/render';
|
||||
this.renderFrame(src, single ? 0 : this.durationMs(item));
|
||||
};
|
||||
|
||||
PlaylistPlayer.prototype.renderFrame = function (src, advanceMs, allow) {
|
||||
var f = document.createElement('iframe');
|
||||
f.setAttribute('frameborder', '0');
|
||||
f.setAttribute('allowfullscreen', '');
|
||||
if (allow) f.setAttribute('allow', allow);
|
||||
f.src = src;
|
||||
this.stage.appendChild(f);
|
||||
if (advanceMs > 0) this.schedule(advanceMs);
|
||||
};
|
||||
|
||||
PlaylistPlayer.prototype.youtubeId = function (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; // bare id
|
||||
return null;
|
||||
};
|
||||
7
tizen/js/socket.io.min.js
vendored
Normal file
7
tizen/js/socket.io.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue