screentinker/server/player/index.html

1572 lines
72 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>ScreenTinker Player</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: -apple-system, sans-serif; }
/* Setup Screen */
#setupScreen {
position: fixed; inset: 0; background: #111827; display: flex; flex-direction: column;
align-items: center; justify-content: center; z-index: 1000; color: #f1f5f9;
}
#setupScreen h1 { font-size: 36px; color: #3b82f6; margin-bottom: 8px; }
#setupScreen .subtitle { color: #94a3b8; font-size: 16px; margin-bottom: 48px; }
#setupScreen .form { width: 400px; max-width: 90vw; }
#setupScreen label { display: block; font-size: 14px; color: #94a3b8; margin-bottom: 8px; }
#setupScreen input { width: 100%; padding: 12px; background: #0f172a; border: 1px solid #334155;
border-radius: 8px; color: #f1f5f9; font-size: 16px; margin-bottom: 24px; outline: none; }
#setupScreen input:focus { border-color: #3b82f6; }
#setupScreen button { width: 100%; padding: 12px; background: #3b82f6; color: white;
border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; }
#setupScreen button:hover { background: #2563eb; }
#setupScreen button:disabled { opacity: 0.5; cursor: not-allowed; }
.pairing-code { font-size: 72px; font-weight: 700; color: #3b82f6; font-family: monospace;
letter-spacing: 12px; margin: 24px 0; }
.pairing-hint { color: #64748b; font-size: 14px; }
.status-msg { color: #94a3b8; font-size: 14px; margin-top: 16px; }
.spinner { width: 40px; height: 40px; border: 3px solid #334155; border-top-color: #3b82f6;
border-radius: 50%; animation: spin 1s linear infinite; margin: 24px auto; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Player */
#playerContainer { position: fixed; inset: 0; background: #000; }
/* Fullscreen single-zone playback: YouTube's IFrame API measures the placeholder
at construction time. If that happens before layout settles, YT bakes in a
300x150 fallback as inline pixel dimensions on the iframe, which our %-based
rules can't override. Force fullscreen via absolute positioning + !important. */
#playerContainer > iframe,
#playerContainer > div > iframe {
position: absolute !important;
top: 0 !important; left: 0 !important;
width: 100% !important; height: 100% !important;
border: none !important; display: block !important;
}
.zone { position: absolute; overflow: hidden; }
.zone video { width: 100%; height: 100%; object-fit: cover; }
.zone img { width: 100%; height: 100%; object-fit: cover; }
.zone iframe { width: 100%; height: 100%; border: none; }
/* Video wall mode.
wall-stage maps the wall's player_rect into this device's viewport
using vw/vh — so the device fills its full viewport edge-to-edge
(no pillarbox at the seam between adjacent screens).
object-fit:fill is intentional: it stretches the source to the stage,
which keeps vertical position identical between devices that share
a viewport height — without that, cover-cropping on different stage
aspects (different innerWidths) shifts content vertically. */
#playerContainer.wall-mode { overflow: hidden; background: #000; }
.wall-stage { position: absolute; }
.wall-stage > video,
.wall-stage > img { width: 100%; height: 100%; object-fit: fill; display: block; }
.wall-stage > iframe { width: 100%; height: 100%; border: none; display: block; }
.wall-mode #playerContainer > iframe,
.wall-mode #playerContainer > div > iframe { position: static !important; width: 100% !important; height: 100% !important; }
/* Status overlay */
#statusOverlay {
position: fixed; inset: 0; background: #000; display: flex; flex-direction: column;
align-items: center; justify-content: center; color: #94a3b8; z-index: 500;
}
#statusOverlay h2 { color: #3b82f6; font-size: 28px; margin-bottom: 8px; }
#statusOverlay p { font-size: 16px; }
</style>
</head>
<body>
<!-- Setup Screen -->
<div id="setupScreen">
<h1>ScreenTinker</h1>
<div class="subtitle">Web Player</div>
<div class="form" id="urlForm">
<label>Server URL</label>
<input type="url" id="serverUrl" placeholder="https://sign.yourdomain.com" autofocus>
<button id="connectBtn">Connect</button>
</div>
<div id="pairingSection" style="display:none;text-align:center">
<p>Pairing Code</p>
<div class="pairing-code" id="pairingCode">------</div>
<p class="pairing-hint">Enter this code in the dashboard to pair this display</p>
</div>
<div class="spinner" id="setupSpinner" style="display:none"></div>
<div class="status-msg" id="setupStatus"></div>
</div>
<!-- Player Container -->
<div id="playerContainer" style="display:none"></div>
<!-- Status Overlay -->
<div id="statusOverlay" style="display:none">
<div class="spinner"></div>
<h2>ScreenTinker</h2>
<p id="statusText" data-i18n="connecting">Connecting...</p>
</div>
<script src="/socket.io/socket.io.js"></script>
<script>
// ==================== i18n ====================
// Lightweight inline i18n for the player. The player is a standalone page
// on display devices — it doesn't import the dashboard's i18n module.
// Keep keys in sync with the player overlay strings; falls back to en.
const PLAYER_I18N = {
en: {
web_player: 'Web Player',
server_url: 'Server URL',
server_url_placeholder: 'https://sign.yourdomain.com',
connect: 'Connect',
pairing_code: 'Pairing Code',
pairing_hint: 'Enter this code in the dashboard to pair this display',
connecting: 'Connecting...',
connecting_muted: 'Connecting (audio muted)...',
info_title: 'ScreenTinker Web Player',
info_close_hint: 'Press Back again or click to close',
info_device_id: 'Device ID',
info_device_name: 'Device Name',
info_server: 'Server',
info_status: 'Status',
info_now_playing: 'Now Playing',
info_resolution: 'Resolution',
info_uptime: 'Uptime',
info_platform: 'Platform',
info_cache: 'Cache',
info_connected: 'Connected',
info_disconnected: 'Disconnected',
info_active: 'Active',
info_inactive: 'Inactive',
info_nothing: 'Nothing',
info_na: 'N/A',
info_sw: 'Service Worker',
},
es: {
web_player: 'Reproductor web', server_url: 'URL del servidor', server_url_placeholder: 'https://signage.tudominio.com', connect: 'Conectar', pairing_code: 'Código de vinculación', pairing_hint: 'Ingresa este código en el panel para vincular esta pantalla', connecting: 'Conectando...', connecting_muted: 'Conectando (audio silenciado)...', info_title: 'Reproductor web ScreenTinker', info_close_hint: 'Presiona Atrás de nuevo o haz clic para cerrar', info_device_id: 'ID del dispositivo', info_device_name: 'Nombre del dispositivo', info_server: 'Servidor', info_status: 'Estado', info_now_playing: 'Reproduciendo', info_resolution: 'Resolución', info_uptime: 'Tiempo activo', info_platform: 'Plataforma', info_cache: 'Caché', info_connected: 'Conectado', info_disconnected: 'Desconectado', info_active: 'Activo', info_inactive: 'Inactivo', info_nothing: 'Nada', info_na: 'N/D', info_sw: 'Service Worker',
},
fr: {
web_player: 'Lecteur web', server_url: 'URL du serveur', server_url_placeholder: 'https://signage.votredomaine.com', connect: 'Connecter', pairing_code: 'Code dappairage', pairing_hint: 'Saisissez ce code dans le tableau de bord pour apparier cet écran', connecting: 'Connexion...', connecting_muted: 'Connexion (audio coupé)...', info_title: 'Lecteur web ScreenTinker', info_close_hint: 'Appuyez à nouveau sur Retour ou cliquez pour fermer', info_device_id: 'ID de lappareil', info_device_name: 'Nom de lappareil', info_server: 'Serveur', info_status: 'État', info_now_playing: 'En lecture', info_resolution: 'Résolution', info_uptime: 'Disponibilité', info_platform: 'Plateforme', info_cache: 'Cache', info_connected: 'Connecté', info_disconnected: 'Déconnecté', info_active: 'Actif', info_inactive: 'Inactif', info_nothing: 'Rien', info_na: 'N/D', info_sw: 'Service Worker',
},
de: {
web_player: 'Web-Player', server_url: 'Server-URL', server_url_placeholder: 'https://signage.ihredomain.com', connect: 'Verbinden', pairing_code: 'Kopplungscode', pairing_hint: 'Geben Sie diesen Code im Dashboard ein, um diesen Bildschirm zu koppeln', connecting: 'Verbindung wird hergestellt...', connecting_muted: 'Verbindung (Audio stummgeschaltet)...', info_title: 'ScreenTinker Web-Player', info_close_hint: 'Erneut Zurück drücken oder klicken zum Schließen', info_device_id: 'Geräte-ID', info_device_name: 'Gerätename', info_server: 'Server', info_status: 'Status', info_now_playing: 'Aktuelle Wiedergabe', info_resolution: 'Auflösung', info_uptime: 'Betriebszeit', info_platform: 'Plattform', info_cache: 'Cache', info_connected: 'Verbunden', info_disconnected: 'Getrennt', info_active: 'Aktiv', info_inactive: 'Inaktiv', info_nothing: 'Nichts', info_na: 'N/V', info_sw: 'Service Worker',
},
pt: {
web_player: 'Player web', server_url: 'URL do servidor', server_url_placeholder: 'https://sign.seudominio.com', connect: 'Conectar', pairing_code: 'Código de pareamento', pairing_hint: 'Digite este código no painel para parear esta tela', connecting: 'Conectando...', connecting_muted: 'Conectando (áudio mudo)...', info_title: 'Player web ScreenTinker', info_close_hint: 'Pressione Voltar novamente ou clique para fechar', info_device_id: 'ID do dispositivo', info_device_name: 'Nome do dispositivo', info_server: 'Servidor', info_status: 'Status', info_now_playing: 'Reproduzindo', info_resolution: 'Resolução', info_uptime: 'Tempo ativo', info_platform: 'Plataforma', info_cache: 'Cache', info_connected: 'Conectado', info_disconnected: 'Desconectado', info_active: 'Ativo', info_inactive: 'Inativo', info_nothing: 'Nada', info_na: 'N/D', info_sw: 'Service Worker',
},
};
const PLAYER_LANG = (() => {
const stored = localStorage.getItem('rd_lang');
const detected = (stored || navigator.language || 'en').split('-')[0];
return PLAYER_I18N[detected] ? detected : 'en';
})();
const _t = (k) => (PLAYER_I18N[PLAYER_LANG] && PLAYER_I18N[PLAYER_LANG][k]) || PLAYER_I18N.en[k] || k;
// Apply translations to the static setup screen markup
document.addEventListener('DOMContentLoaded', () => {
const setSubtitle = document.querySelector('#setupScreen .subtitle');
if (setSubtitle) setSubtitle.textContent = _t('web_player');
const lblServer = document.querySelector('#urlForm label');
if (lblServer) lblServer.textContent = _t('server_url');
const inputServer = document.getElementById('serverUrl');
if (inputServer) inputServer.placeholder = _t('server_url_placeholder');
const connectBtn = document.getElementById('connectBtn');
if (connectBtn) connectBtn.textContent = _t('connect');
const pairingP = document.querySelector('#pairingSection p:first-child');
if (pairingP) pairingP.textContent = _t('pairing_code');
const pairingHint = document.querySelector('#pairingSection .pairing-hint');
if (pairingHint) pairingHint.textContent = _t('pairing_hint');
// Translate any element with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const k = el.getAttribute('data-i18n');
if (k) el.textContent = _t(k);
});
});
// ==================== Config ====================
const STORAGE_KEY = 'rd_web_player';
const HEARTBEAT_INTERVAL = 15000;
const PLAYLIST_REFRESH_INTERVAL = 60000;
function getConfig() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { return {}; }
}
function saveConfig(cfg) { localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); }
const PLAYLIST_CACHE_KEY = 'rd_playlist_cache';
function savePlaylistCache(items) {
try { localStorage.setItem(PLAYLIST_CACHE_KEY, JSON.stringify(items)); } catch {}
}
function loadPlaylistCache() {
try { return JSON.parse(localStorage.getItem(PLAYLIST_CACHE_KEY) || '[]'); } catch { return []; }
}
// ==================== State ====================
let socket = null;
let config = getConfig();
let playlist = [];
let currentIndex = -1;
let isPlaying = false;
let heartbeatTimer = null;
let refreshTimer = null;
let remoteStreaming = false;
let streamTimer = null;
let layout = null;
let zones = {};
// Tracks whether the user has gestured in *this* page load. Browser autoplay
// policy is per-document — a flag from a previous session does NOT grant
// autoplay rights here, so we always start as false. The cold-load tap overlay
// is the only thing that flips this to true (or its 5s timeout, which keeps
// playback muted).
let userHasInteracted = false;
let advanceTimer = null;
// Video wall state. wallConfig is the tile assignment from the server
// (null when this device isn't in a wall). The leader runs the playlist
// normally and broadcasts wall:sync every second; followers don't run
// their own advance timers and instead align their currentIndex and
// video position to whatever the leader is playing.
let wallConfig = null;
let wallSyncTimer = null;
let lastWallSync = null;
let currentVideoEl = null;
let currentItemStartedAt = 0;
// Followers in a video wall must stay silent — N copies of the same audio
// slightly out of sync produce a flanged echo across the wall. Only the
// leader is allowed to make sound. This helper is the single source of
// truth used by every code path that would otherwise unmute audio.
function isWallFollower() { return !!(wallConfig && !wallConfig.is_leader); }
// YouTube player state. Declared up front because the cached-playlist restore
// (a few lines below) may synchronously call into createYoutubeEmbed before the
// script reaches the original declaration site, which used to throw a temporal
// dead zone error.
let ytApiReady = false;
let ytApiCallbacks = [];
let activeYtPlayer = null;
let ytGeneration = 0;
// AudioContext is created lazily on the first user gesture. Resuming it
// is what convinces stricter browsers (Firefox) that the site is "user-
// activated" for audio. Reused across all later unmute attempts.
let _audioCtx = null;
function unlockAudioContext() {
try {
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (_audioCtx.state === 'suspended') _audioCtx.resume().catch(() => {});
// Play a 1-sample silent buffer to fully promote the context to running.
const buf = _audioCtx.createBuffer(1, 1, 22050);
const src = _audioCtx.createBufferSource();
src.buffer = buf;
src.connect(_audioCtx.destination);
src.start(0);
} catch (e) { /* harmless */ }
}
// Try to unmute and play the leader video. MUST be called synchronously
// from inside a real user-gesture handler — any preceding await would
// throw away the gesture's user-activation in stricter browsers (Firefox).
// Returns immediately; the play() promise is resolved/rejected async.
function tryUnmuteLeader() {
const video = document.querySelector('#playerContainer video');
if (!video) return false;
if (!video.muted) return true;
// Capture state, unmute, do a fresh pause+play within the same task.
// Firefox is more permissive when play() is treated as a brand-new
// gesture-driven start rather than the unmute of an autoplaying video.
const t = video.currentTime;
video.muted = false;
video.volume = 1.0;
video.pause();
const p = video.play();
if (p && typeof p.then === 'function') {
p.then(() => {
if (isFinite(t)) { try { video.currentTime = t; } catch {} }
console.log('[wall/audio] unmuted play() ok muted=' + video.muted + ' volume=' + video.volume);
hideEnableAudioPrompt();
}).catch((err) => {
console.warn('[wall/audio] unmuted play() rejected: ' + (err?.name || err?.message || err));
// Remute so playback continues; surface the prompt for explicit consent.
video.muted = true;
video.play().catch((e2) => console.error('[wall/audio] muted-fallback play() failed: ' + (e2?.name || e2?.message || e2)));
showEnableAudioPrompt();
});
}
return true;
}
// Visible "tap to enable audio" prompt for leaders whose unmute failed.
// The user clicking this prompt is itself a fresh gesture, which is the
// most reliable path past Firefox's autoplay restriction.
function showEnableAudioPrompt() {
if (isWallFollower()) return;
if (document.getElementById('enableAudioPrompt')) return;
const ov = document.createElement('div');
ov.id = 'enableAudioPrompt';
ov.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.88);color:#fff;padding:12px 22px;border-radius:8px;cursor:pointer;z-index:10000;font-size:14px;display:flex;gap:10px;align-items:center;box-shadow:0 4px 16px rgba(0,0,0,0.4)';
ov.innerHTML = '<span style="font-size:20px">&#128263;</span><span>Tap to enable audio</span>';
ov.addEventListener('click', () => {
unlockAudioContext();
tryUnmuteLeader();
});
document.body.appendChild(ov);
}
function hideEnableAudioPrompt() {
document.getElementById('enableAudioPrompt')?.remove();
}
// Track user interaction for autoplay policy
['click', 'touchstart', 'keydown'].forEach(evt => {
document.addEventListener(evt, () => {
const wasFirst = !userHasInteracted;
userHasInteracted = true;
// First gesture: prime the AudioContext. This signals "site activated"
// to Firefox and unlocks subsequent <video> unmute attempts.
if (wasFirst) unlockAudioContext();
// Followers in a video wall must stay muted forever — even after a
// user gesture. Otherwise tapping a follower screen would unmute it
// and cause echo with the leader.
if (isWallFollower()) return;
if (wasFirst) console.log('[wall/audio] first user gesture detected — attempting unmute');
tryUnmuteLeader();
// Unmute YouTube player if active
if (activeYtPlayer && typeof activeYtPlayer.unMute === 'function') {
try { activeYtPlayer.unMute(); activeYtPlayer.setVolume(100); console.log('Unmuted YouTube player'); } catch {}
}
}, { once: false });
});
// ==================== Browser Fingerprint ====================
function generateBrowserFingerprint() {
const components = [
navigator.userAgent,
navigator.language,
screen.width + 'x' + screen.height,
screen.colorDepth,
new Date().getTimezoneOffset(),
navigator.hardwareConcurrency || 0,
navigator.platform,
// Canvas fingerprint
(() => {
try {
const c = document.createElement('canvas');
const ctx = c.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillText('ScreenTinker fingerprint', 2, 2);
return c.toDataURL().slice(-50);
} catch { return ''; }
})(),
];
// Simple hash
const str = components.join('|');
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return 'web-' + Math.abs(hash).toString(36) + '-' + str.length.toString(36);
}
// ==================== Boot ====================
// Function used by connect button and auto-connect
let autoContinueTimer;
function connectBtnFunc() {
if (autoContinueTimer) {
clearInterval(autoContinueTimer);
autoContinueTimer = null;
document.getElementById('connectBtn').textContent = _t('connect');
}
unlockAudio();
const url = document.getElementById('serverUrl').value.trim().replace(/\/$/, '');
if (!url) return;
config.serverUrl = url;
saveConfig(config);
document.getElementById('connectBtn').disabled = true;
document.getElementById('setupSpinner').style.display = 'block';
document.getElementById('setupStatus').textContent = 'Connecting...';
connect(url);
};
// Auto-detect server URL from origin since player is served from the same server
if (!config.serverUrl) {
config.serverUrl = window.location.origin;
saveConfig(config);
}
if (config.serverUrl && config.deviceId && config.paired) {
// Restore cached playlist immediately so content plays even if offline
const cachedPlaylist = loadPlaylistCache();
if (cachedPlaylist.length > 0) {
console.log('Restored cached playlist:', cachedPlaylist.length, 'items');
playlist = cachedPlaylist;
currentIndex = 0;
isPlaying = true;
document.getElementById('setupScreen').style.display = 'none';
playCurrentItem();
}
// Always show the tap overlay on cold load. Browser autoplay policy is
// per-document — a localStorage flag from a prior session does not grant
// audio autoplay to a fresh page. The overlay auto-dismisses after 5s and
// connects muted, so unattended kiosks still recover without a human tap.
{
const tapOverlay = document.createElement('div');
tapOverlay.style.cssText = 'position:fixed;inset:0;background:#111827;z-index:2000;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer';
tapOverlay.innerHTML = `
<h1 style="color:#3b82f6;font-size:36px;font-family:sans-serif;margin-bottom:12px">ScreenTinker</h1>
<p style="color:#94a3b8;font-size:18px;font-family:sans-serif">Tap anywhere to start</p>
<p style="color:#64748b;font-size:13px;font-family:sans-serif;margin-top:24px">Audio requires user interaction</p>
`;
tapOverlay.onclick = () => {
unlockAudio();
tapOverlay.remove();
if (!isPlaying) showStatus(_t('connecting'));
connect(config.serverUrl);
};
document.body.appendChild(tapOverlay);
// Auto-dismiss after 5 seconds if no interaction (plays muted)
setTimeout(() => {
if (tapOverlay.parentNode) {
tapOverlay.remove();
if (!isPlaying) showStatus(_t('connecting_muted'));
connect(config.serverUrl);
}
}, 5000);
}
} else {
// Auto-Continue after 5s if not configured. If user interacts with form (typing in the box), stop the timer.
let countdown = 5;
const connectBtn = document.getElementById('connectBtn');
connectBtn.textContent = `${_t('connect')} (${countdown})`;
autoContinueTimer = setInterval(() => {
countdown--;
if (countdown > 0) {
connectBtn.textContent = `${_t('connect')} (${countdown})`;
} else {
clearInterval(autoContinueTimer);
connectBtn.textContent = _t('connect');
connectBtnFunc()
}
}, 1000);
document.getElementById('serverUrl').addEventListener('input', () => {
if (countdown > 0) {
clearInterval(autoContinueTimer);
connectBtn.textContent = _t('connect');
}
});
}
// ==================== Setup UI ====================
const savedUrl = config.serverUrl || window.location.origin;
document.getElementById('serverUrl').value = savedUrl;
// Unlock audio on any user interaction
function unlockAudio() {
userHasInteracted = true;
// Create and resume AudioContext (unlocks audio for the session)
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
ctx.resume().then(() => { console.log('AudioContext unlocked'); });
// Play a silent buffer to fully unlock
const buf = ctx.createBuffer(1, 1, 22050);
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
src.start(0);
} catch(e) { console.warn('Audio unlock failed:', e); }
// Wall followers must stay muted — leader is the only audio source.
if (isWallFollower()) return;
// Unmute any playing HTML5 video
document.querySelectorAll('video').forEach(v => { v.muted = false; });
// Unmute the active YouTube embed (iframe — querySelectorAll('video') misses it)
try {
if (activeYtPlayer && typeof activeYtPlayer.unMute === 'function') {
activeYtPlayer.unMute();
activeYtPlayer.setVolume(100);
}
} catch (e) { console.warn('YT unmute failed:', e); }
}
document.getElementById('connectBtn').onclick = connectBtnFunc;
// ==================== Socket Connection ====================
function connect(serverUrl) {
if (socket) { socket.disconnect(); socket = null; }
socket = io(serverUrl + '/device', {
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000,
timeout: 20000,
// Prefer WebSocket but allow polling fallback. Socket.IO default is
// polling-first with an upgrade dance that's fragile on TV WebKits
// (LG webOS especially). Reversing the order opens a WebSocket directly;
// if that fails (rare - blocked by firewall), it falls back to polling
// on the same connect attempt. Tradeoff: WS-blocked networks add a few
// seconds to first connect while WS times out. Worth it for the common
// case where WS is fine but the upgrade dance was hanging the device.
transports: ['websocket', 'polling'],
});
socket.on('connect', () => {
console.log('Connected');
register();
});
socket.on('disconnect', () => {
console.log('Disconnected');
stopHeartbeat();
});
socket.on('connect_error', (err) => {
document.getElementById('setupStatus').textContent = 'Connection failed: ' + err.message;
document.getElementById('setupSpinner').style.display = 'none';
document.getElementById('connectBtn').disabled = false;
});
socket.on('device:registered', (data) => {
config.deviceId = data.device_id;
if (data.device_token) config.deviceToken = data.device_token;
saveConfig(config);
console.log('Registered:', data.device_id);
if (!config.paired) {
// Show pairing code
document.getElementById('urlForm').style.display = 'none';
document.getElementById('setupSpinner').style.display = 'none';
document.getElementById('pairingSection').style.display = 'block';
document.getElementById('pairingCode').textContent = config.pairingCode || '------';
document.getElementById('setupStatus').textContent = '';
}
startHeartbeat();
startPlaylistRefresh();
startVersionCheck();
});
socket.on('device:paired', (data) => {
config.paired = true;
config.deviceName = data.name;
saveConfig(config);
console.log('Paired as:', data.name);
document.getElementById('setupScreen').style.display = 'none';
showStatus('Waiting for content...');
});
socket.on('device:unpaired', () => {
console.warn('Device not found on server — clearing credentials');
delete config.deviceId;
delete config.deviceToken;
config.paired = false;
saveConfig(config);
savePlaylistCache([]);
document.getElementById('setupScreen').style.display = 'flex';
document.getElementById('urlForm').style.display = 'block';
document.getElementById('pairingSection').style.display = 'none';
document.getElementById('setupStatus').textContent = 'Device was removed from server. Please reconnect.';
});
socket.on('device:auth-error', (data) => {
console.warn('Device auth rejected:', data?.error || 'unknown');
delete config.deviceId;
delete config.deviceToken;
config.paired = false;
saveConfig(config);
document.getElementById('setupScreen').style.display = 'flex';
document.getElementById('urlForm').style.display = 'block';
document.getElementById('pairingSection').style.display = 'none';
document.getElementById('setupStatus').textContent = 'Authentication failed. Please re-pair this device.';
});
socket.on('device:playlist-update', (data) => {
console.log('Playlist update:', data.assignments?.length, 'items');
handlePlaylistUpdate(data);
});
// Video wall sync (leader broadcasts; followers align)
socket.on('wall:sync', (data) => {
if (!wallConfig || wallConfig.is_leader) return;
if (data.wall_id !== wallConfig.wall_id) return;
lastWallSync = data;
// If leader switched item, jump to it
if (typeof data.current_index === 'number' && data.current_index !== currentIndex && playlist.length > 0) {
currentIndex = ((data.current_index % playlist.length) + playlist.length) % playlist.length;
playCurrentItem();
}
// Hold the follower close to the leader's clock. Account for relay
// latency: the leader was at position_sec when sent_at was stamped;
// by now a bit more time has elapsed, so target = position + latency.
if (currentVideoEl && typeof data.position_sec === 'number') {
const now = Date.now();
const latency = data.sent_at ? Math.max(0, (now - data.sent_at) / 1000) : 0;
const target = data.position_sec + latency;
const drift = (currentVideoEl.currentTime || 0) - target;
const absDrift = Math.abs(drift);
if (absDrift > 0.3 && isFinite(currentVideoEl.duration) && target < currentVideoEl.duration) {
// Big drift: hard seek and reset rate.
try { currentVideoEl.currentTime = target; } catch (_) {}
try { currentVideoEl.playbackRate = 1.0; } catch (_) {}
} else if (absDrift > 0.05) {
// Small drift: nudge playbackRate to converge gently. ±3% is
// imperceptible on most content but pulls in 50ms drift in <2s.
try { currentVideoEl.playbackRate = drift > 0 ? 0.97 : 1.03; } catch (_) {}
} else if (currentVideoEl.playbackRate !== 1.0) {
// In-window: ride at normal rate.
try { currentVideoEl.playbackRate = 1.0; } catch (_) {}
}
}
});
// Leader receives a sync-request from a (re)connecting follower and
// immediately broadcasts its position so the requester can align without
// waiting for the next periodic tick.
socket.on('wall:sync-request', (data) => {
if (!wallConfig?.is_leader) return;
if (data?.wall_id && data.wall_id !== wallConfig.wall_id) return;
console.log('[wall] sync-request received from ' + data?.requested_by + ', broadcasting current position');
emitWallSync();
});
socket.on('device:screenshot-request', () => { console.log('Screenshot requested'); captureAndSend(); });
socket.on('device:remote-start', () => { console.log('Remote start received'); remoteStreaming = true; startStreaming(); });
socket.on('device:remote-stop', () => { console.log('Remote stop received'); remoteStreaming = false; stopStreaming(); });
socket.on('device:remote-touch', (data) => {
// Simulate click at normalized coordinates within the player
const container = document.getElementById('playerContainer');
if (!container) return;
const x = data.x * container.offsetWidth;
const y = data.y * container.offsetHeight;
const el = document.elementFromPoint(x, y);
if (el) el.click();
console.log('Touch:', data.x, data.y, '-> element:', el?.tagName);
});
socket.on('device:remote-key', (data) => {
console.log('Key:', data.keycode);
const video = document.querySelector('#playerContainer video');
switch (data.keycode) {
case 'KEYCODE_DPAD_RIGHT':
// Skip to next content
nextItem();
break;
case 'KEYCODE_DPAD_LEFT':
// Go to previous content
currentIndex = (currentIndex - 2 + playlist.length) % playlist.length;
nextItem();
break;
case 'KEYCODE_DPAD_CENTER':
case 'KEYCODE_ENTER':
// Toggle play/pause
if (video) { video.paused ? video.play() : video.pause(); }
break;
case 'KEYCODE_VOLUME_UP':
// Wall followers ignore volume changes — they stay silent.
if (video && !isWallFollower()) { video.volume = Math.min(1, video.volume + 0.1); video.muted = false; }
break;
case 'KEYCODE_VOLUME_DOWN':
if (video) { video.volume = Math.max(0, video.volume - 0.1); }
break;
case 'KEYCODE_MENU':
// Toggle mute (followers can't unmute)
if (video && !(isWallFollower() && video.muted)) { video.muted = !video.muted; }
break;
case 'KEYCODE_HOME':
// Go back to first item
currentIndex = -1;
nextItem();
break;
case 'KEYCODE_BACK':
// Show/hide status overlay with device info
const overlay = document.getElementById('infoOverlay');
if (overlay) { overlay.style.display = overlay.style.display === 'none' ? 'flex' : 'none'; }
break;
case 'KEYCODE_POWER':
// Toggle screen (show black overlay)
toggleScreenOff();
break;
}
});
socket.on('device:command', (data) => {
console.log('Command:', data.type);
if (data.type === 'refresh') location.reload();
if (data.type === 'launch') { document.getElementById('screenOffOverlay')?.remove(); }
if (data.type === 'screen_off') toggleScreenOff();
if (data.type === 'screen_on') { document.getElementById('screenOffOverlay')?.remove(); }
});
}
function register() {
const data = {};
if (config.deviceId && config.paired) {
data.device_id = config.deviceId;
if (config.deviceToken) data.device_token = config.deviceToken;
} else {
const code = String(Math.floor(100000 + Math.random() * 900000));
config.pairingCode = code;
saveConfig(config);
data.pairing_code = code;
}
data.device_info = {
android_version: 'Web/' + navigator.userAgent.split(' ').pop(),
app_version: '1.1.0-web',
screen_width: screen.width,
screen_height: screen.height,
};
// Browser fingerprint (survives localStorage clear)
data.fingerprint = generateBrowserFingerprint();
console.log(`[register] device_id=${data.device_id || 'none'}, has_token=${!!data.device_token}, token_len=${data.device_token?.length || 0}, paired=${config.paired}, pairing_code=${data.pairing_code || 'none'}`);
socket.emit('device:register', data);
}
// ==================== Heartbeat ====================
function startHeartbeat() {
stopHeartbeat();
heartbeatTimer = setInterval(() => {
if (!socket?.connected || !config.deviceId) return;
socket.emit('device:heartbeat', {
device_id: config.deviceId,
telemetry: {
battery_level: null,
battery_charging: false,
storage_free_mb: null,
storage_total_mb: null,
ram_free_mb: null,
ram_total_mb: null,
cpu_usage: null,
wifi_ssid: 'Web Player',
wifi_rssi: null,
uptime_seconds: Math.floor(performance.now() / 1000),
}
});
}, HEARTBEAT_INTERVAL);
}
function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }
function startPlaylistRefresh() {
// No longer needed — server pushes playlist updates instantly via WebSocket.
// Kept as a fallback with a long interval in case a push is missed.
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(() => {
if (socket?.connected && config.deviceId && config.paired) {
const data = { device_id: config.deviceId, device_info: {} };
if (config.deviceToken) data.device_token = config.deviceToken;
console.log(`[refresh-register] device_id=${config.deviceId}, has_token=${!!config.deviceToken}`);
socket.emit('device:register', data);
}
}, 300000); // 5 minutes fallback
}
// ==================== Auto-reload on code update ====================
let knownServerHash = null;
let versionCheckTimer = null;
function startVersionCheck() {
if (versionCheckTimer) clearInterval(versionCheckTimer);
// Initial fetch to learn current hash
fetch(config.serverUrl + '/api/version').then(r => r.json()).then(data => {
knownServerHash = data.hash;
console.log('Server version:', data.version, 'hash:', data.hash);
}).catch(() => {});
// Poll every 30s
versionCheckTimer = setInterval(() => {
fetch(config.serverUrl + '/api/version').then(r => r.json()).then(data => {
if (knownServerHash && data.hash !== knownServerHash) {
console.log('Server code updated, reloading...', knownServerHash, '->', data.hash);
location.reload();
}
}).catch(() => {});
}, 30000);
}
// ==================== Video Wall ====================
// Convert a wall_config payload into CSS that sizes & positions the wall
// stage so this device's tile is the visible portion. Each tile is
// 100vw × 100vh; the stage is the full grid, translated by this tile's
// grid position (plus bezel offsets in px between tiles).
function applyWallMode(config) {
const container = document.getElementById('playerContainer');
// Tear down previous wall mode (clear sync timer regardless of new state)
if (wallSyncTimer) { clearInterval(wallSyncTimer); wallSyncTimer = null; }
lastWallSync = null;
if (!config) {
wallConfig = null;
container.classList.remove('wall-mode');
console.log('[wall] exited wall mode');
return;
}
wallConfig = config;
container.classList.add('wall-mode');
console.log('[wall] applyWallMode wall=' + config.wall_id + ' is_leader=' + config.is_leader + ' userHasInteracted=' + userHasInteracted);
// Enforce the audio rule on the currently-mounted video right now.
// If the role flipped (e.g., leader was reassigned mid-stream), the
// existing video element keeps its old muted state until we touch it.
if (currentVideoEl) {
if (config.is_leader) {
// Defer to autoplay policy — leader can be unmuted once the user
// has gestured. Don't yank audio if it's already playing.
} else {
if (!currentVideoEl.muted) currentVideoEl.muted = true;
}
}
if (config.is_leader) {
// Leader emits at 4Hz so followers can apply small playbackRate
// corrections instead of jerk-seeking. Higher rates would saturate
// the relay; 4Hz balances tightness against server load.
wallSyncTimer = setInterval(emitWallSync, 250);
// Immediate broadcast so any follower that's already up aligns now,
// without waiting up to 250ms for the first scheduled tick. This
// also covers a leader reclaiming the role after a reconnect.
setTimeout(emitWallSync, 100);
} else {
// Follower: ask the leader for its current position. Without this,
// the screen shows the start of the current item until the leader's
// next periodic tick (up to ~1s of visible drift on a fresh join).
if (socket?.connected) {
console.log('[wall] follower emitting sync-request for wall ' + config.wall_id);
socket.emit('wall:sync-request', { wall_id: config.wall_id });
}
}
}
function emitWallSync() {
if (!wallConfig?.is_leader || !socket?.connected || playlist.length === 0) return;
const item = playlist[currentIndex];
if (!item) return;
const position = currentVideoEl
? (currentVideoEl.currentTime || 0)
: Math.max(0, (Date.now() - currentItemStartedAt) / 1000);
socket.emit('wall:sync', {
wall_id: wallConfig.wall_id,
device_id: config.deviceId,
current_index: currentIndex,
content_id: item.content_id || null,
position_sec: position,
sent_at: Date.now(),
});
}
// Map the player rect into this device's viewport using vw/vh so the
// viewport fills edge-to-edge (no pillarbox at the seam between adjacent
// screens). With object-fit:fill on the video, the source stretches to
// the stage — which keeps the vertical position of every source pixel
// identical across devices that share a viewport height (1vh maps to
// the same physical pixel on each).
function styleWallStage(stageEl) {
if (!wallConfig?.screen_rect || !wallConfig?.player_rect) return;
const s = wallConfig.screen_rect;
const p = wallConfig.player_rect;
if (!s.w || !s.h) return;
const left = ((p.x - s.x) / s.w) * 100;
const top = ((p.y - s.y) / s.h) * 100;
const width = (p.w / s.w) * 100;
const height = (p.h / s.h) * 100;
const dev = (config.deviceId || '?').slice(0, 8);
console.log('[wall/render ' + dev + '] screen_rect: ' + JSON.stringify(s) + ' player_rect: ' + JSON.stringify(p));
console.log('[wall/render ' + dev + '] viewport: ' + window.innerWidth + 'x' + window.innerHeight + ' DPR=' + window.devicePixelRatio);
console.log('[wall/render ' + dev + '] stage: left=' + left.toFixed(4) + 'vw top=' + top.toFixed(4) + 'vh width=' + width.toFixed(4) + 'vw height=' + height.toFixed(4) + 'vh');
stageEl.style.left = left + 'vw';
stageEl.style.top = top + 'vh';
stageEl.style.width = width + 'vw';
stageEl.style.height = height + 'vh';
stageEl.style.transform = '';
}
// No-op kept for callers that bind a resize listener (kept around in case
// future zoom/orientation tweaks need it). vw/vh stage updates with the
// viewport automatically, so explicit re-style isn't needed today.
function bindWallResizeOnce() {}
// ==================== Playlist ====================
function handlePlaylistUpdate(data) {
// Check if device is suspended (trial expired / over limit)
if (data.suspended) {
isPlaying = false;
playlist = [];
document.getElementById('playerContainer').style.display = 'none';
const overlay = document.getElementById('statusOverlay');
overlay.style.display = 'flex';
overlay.innerHTML = `
<div style="text-align:center;max-width:500px">
<div style="font-size:64px;margin-bottom:16px">&#9888;</div>
<h2 style="color:#f59e0b;margin-bottom:8px">${data.message || 'Account Suspended'}</h2>
<p style="color:#94a3b8;font-size:16px;margin-bottom:24px">${data.detail || 'Please upgrade your plan.'}</p>
<p style="color:#64748b;font-size:13px">Visit your dashboard to manage your subscription</p>
</div>
`;
return;
}
const newItems = data.assignments || [];
// Build fingerprint from id + url + filename to detect any content change
const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}`).join(',');
const newFp = fingerprint(newItems);
const oldFp = fingerprint(playlist);
// Apply orientation
if (data.orientation) {
const rotations = { 'landscape': '0deg', 'portrait': '90deg', 'landscape-flipped': '180deg', 'portrait-flipped': '270deg' };
document.getElementById('playerContainer').style.transform = `rotate(${rotations[data.orientation] || '0deg'})`;
if (data.orientation.includes('portrait')) {
document.getElementById('playerContainer').style.transformOrigin = 'center center';
document.getElementById('playerContainer').style.width = '100vh';
document.getElementById('playerContainer').style.height = '100vw';
}
}
// Apply (or clear) wall mode. Force re-render when wall config changes
// even if the playlist itself didn't, so leader/follower role transitions
// and tile reassignments take effect immediately.
function wallKey(c) {
if (!c) return '';
const s = c.screen_rect || {}, p = c.player_rect || {};
return `${c.wall_id}:${c.is_leader}:s${s.x},${s.y},${s.w},${s.h}:p${p.x},${p.y},${p.w},${p.h}`;
}
const wallChanged = wallKey(wallConfig) !== wallKey(data.wall_config);
if (wallChanged) applyWallMode(data.wall_config || null);
// A fresh playlist-update on a follower (typical after socket reconnect)
// is a good signal to ask the leader for its current position even when
// the wall config itself didn't change. Cheap, debounced server-side.
if (!wallChanged && wallConfig && !wallConfig.is_leader && socket?.connected) {
socket.emit('wall:sync-request', { wall_id: wallConfig.wall_id });
}
layout = data.layout || null;
if (newFp === oldFp && playlist.length > 0 && !wallChanged) {
console.log('Playlist unchanged');
return;
}
console.log('Playlist changed, updating');
// Capture old state BEFORE mutating so continuity logic can find what was playing.
const identityOf = (x) => x ? `${x.content_id || ''}|${x.widget_id || ''}|${x.remote_url || ''}|${x.filepath || ''}` : '';
const oldPlaylist = playlist;
const oldAnchorIdx = currentIndex;
const oldAnchorId = identityOf(oldPlaylist[oldAnchorIdx]);
playlist = newItems;
savePlaylistCache(playlist);
if (playlist.length === 0) {
teardownCurrentMedia();
showStatus('Waiting for content...');
isPlaying = false;
return;
}
document.getElementById('setupScreen').style.display = 'none';
// Continuity: if the playing item survives the update, keep playing it.
// Just retarget the index pointer - no re-render, no interrupt. It will
// advance naturally via onended -> nextItem.
if (oldAnchorId && oldAnchorId !== '|||') {
const stillThereIdx = playlist.findIndex(x => identityOf(x) === oldAnchorId);
if (stillThereIdx !== -1) {
currentIndex = stillThereIdx;
isPlaying = true;
return;
}
}
// Anchor is gone. Walk forward from the OLD position through the old playlist,
// pick the first item that still exists in the new one. Preserves "what was
// scheduled to play next, that still exists". Wraps past the end naturally.
let nextIdx = -1;
if (oldPlaylist.length > 0 && Number.isFinite(oldAnchorIdx)) {
for (let i = 1; i <= oldPlaylist.length; i++) {
const probe = oldPlaylist[(oldAnchorIdx + i) % oldPlaylist.length];
const probeId = identityOf(probe);
if (!probeId || probeId === '|||') continue;
const found = playlist.findIndex(x => identityOf(x) === probeId);
if (found !== -1) { nextIdx = found; break; }
}
}
if (nextIdx === -1) nextIdx = 0;
currentIndex = nextIdx;
isPlaying = true;
playCurrentItem();
}
function playCurrentItem() {
if (!playlist.length || !Number.isFinite(currentIndex)) {
teardownCurrentMedia();
showStatus('Waiting for content...');
isPlaying = false;
return;
}
if (currentIndex < 0 || currentIndex >= playlist.length) currentIndex = 0;
hideStatus();
const item = playlist[currentIndex];
console.log('Playing:', item.filename, `(${currentIndex + 1}/${playlist.length})`);
currentItemStartedAt = Date.now();
// Only the leader (or single, non-walled players) records a play_start —
// followers would just spam duplicate proof-of-play rows for the same item.
if (socket?.connected && (!wallConfig || wallConfig.is_leader)) {
socket.emit('device:play-event', {
device_id: config.deviceId,
event: 'play_start',
content_id: item.content_id,
content_name: item.filename,
duration_sec: item.duration_sec || null,
});
}
renderContent(item);
// Push an immediate sync so followers don't have to wait up to 1s for
// the next periodic tick before snapping to the new item.
if (wallConfig?.is_leader) emitWallSync();
}
function nextItem() {
// Send play_end for current
if (playlist[currentIndex] && socket?.connected) {
socket.emit('device:play-event', {
device_id: config.deviceId,
event: 'play_end',
content_id: playlist[currentIndex].content_id,
content_name: playlist[currentIndex].filename,
completed: true,
});
}
currentIndex = (currentIndex + 1) % playlist.length;
playCurrentItem();
}
// ==================== Content Rendering ====================
// Extract YouTube video ID from embed URL
function extractVideoId(url) {
try {
const m = url.match(/\/embed\/([a-zA-Z0-9_-]{11})/);
if (m) return m[1];
const u = new URL(url);
return u.searchParams.get('v') || url.match(/([a-zA-Z0-9_-]{11})/)?.[1] || null;
} catch { return null; }
}
// Load YouTube IFrame API once. (ytApiReady / ytApiCallbacks are declared at the
// top of the script alongside the other player state.)
function loadYoutubeApi(cb) {
if (ytApiReady) { cb(); return; }
ytApiCallbacks.push(cb);
if (!document.getElementById('yt-api-script')) {
const tag = document.createElement('script');
tag.id = 'yt-api-script';
tag.src = 'https://www.youtube.com/iframe_api';
document.head.appendChild(tag);
window.onYouTubeIframeAPIReady = () => {
ytApiReady = true;
ytApiCallbacks.forEach(fn => fn());
ytApiCallbacks = [];
};
}
}
function createYoutubeEmbed(src, item, container) {
const videoId = extractVideoId(src);
if (!videoId) {
console.error('Could not extract YouTube video ID from:', src);
if (playlist.length > 1) setTimeout(nextItem, 2000);
return null;
}
// Invalidate any previous player's callbacks
const myGeneration = ++ytGeneration;
// Destroy old player without triggering side effects (callbacks check generation)
if (activeYtPlayer) { try { activeYtPlayer.destroy(); } catch {} activeYtPlayer = null; }
// Create a div for the YT player to replace
const playerDiv = document.createElement('div');
playerDiv.id = 'yt-player-' + Date.now();
playerDiv.style.cssText = 'width:100%;height:100%;background:#000';
container.appendChild(playerDiv);
// Add a click-to-unmute overlay on top of the YouTube iframe
if (!userHasInteracted) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:absolute;inset:0;z-index:10;cursor:pointer;display:flex;align-items:end;justify-content:center;padding-bottom:40px;';
overlay.innerHTML = '<div style="background:rgba(0,0,0,0.7);color:#fff;padding:10px 24px;border-radius:8px;font:14px sans-serif;pointer-events:none">Click to unmute</div>';
overlay.onclick = (e) => {
e.stopPropagation();
userHasInteracted = true;
unlockAudio();
if (activeYtPlayer && typeof activeYtPlayer.unMute === 'function') {
activeYtPlayer.unMute();
activeYtPlayer.setVolume(100);
console.log('Unmuted YouTube player via overlay');
}
overlay.remove();
};
// Don't override container.style.position here — #playerContainer is already
// position:fixed so absolute children anchor to it. Setting position:relative
// collapsed the container to 0 height (no content sizing it in normal flow),
// which made the YT iframe render black.
container.appendChild(overlay);
}
loadYoutubeApi(() => {
// Bail if a newer player was created while we waited for the API
if (myGeneration !== ytGeneration) return;
const shouldLoop = playlist.length <= 1;
let playStartTime = 0;
activeYtPlayer = new YT.Player(playerDiv.id, {
videoId: videoId,
width: '100%',
height: '100%',
playerVars: {
autoplay: 1,
mute: userHasInteracted ? 0 : 1,
controls: 0,
rel: 0,
modestbranding: 1,
loop: shouldLoop ? 1 : 0,
playlist: shouldLoop ? videoId : undefined,
enablejsapi: 1,
origin: window.location.origin,
},
events: {
onReady: (event) => {
if (myGeneration !== ytGeneration) return;
console.log('YouTube player ready:', item.filename);
event.target.playVideo();
if (userHasInteracted) {
event.target.unMute();
event.target.setVolume(100);
}
},
onError: (event) => {
if (myGeneration !== ytGeneration) return;
console.error('YouTube error', event.data, 'for:', item.filename);
if (playlist.length > 1) {
console.log('Skipping unplayable YouTube video');
setTimeout(nextItem, 2000);
}
},
onStateChange: (event) => {
if (myGeneration !== ytGeneration) return;
// Track when video actually starts playing
if (event.data === 1) playStartTime = Date.now();
// YT.PlayerState.ENDED = 0 — advance to next video
// Ignore ENDED if video played for less than 3 seconds (spurious during init)
if (event.data === 0 && !shouldLoop && (Date.now() - playStartTime) > 3000) {
console.log('YouTube video ended:', item.filename);
nextItem();
}
},
},
});
});
// Note: YouTube advancement is handled by onStateChange ENDED event.
// Do NOT use duration_sec timeout here — it defaults to 10s for assignments
// and would cut videos short. The YouTube player tells us when it's done.
return playerDiv;
}
// Stop and release all media in the player. pause() alone leaves the decoder
// buffering on some browsers; removeAttribute('src') + load() is what actually
// releases the decoder and kills audio. Null event handlers so a late onended
// can't fire into a stale playlist state. Queries all <video> elements so
// zone-mode (multi-region) videos get cleaned up too, not just currentVideoEl.
function teardownCurrentMedia() {
if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }
const container = document.getElementById('playerContainer');
if (container) {
container.querySelectorAll('video').forEach(v => {
try {
v.onended = null; v.onerror = null; v.onloadeddata = null;
v.pause();
v.removeAttribute('src');
v.load();
} catch (e) { /* element may already be detached */ }
});
container.innerHTML = '';
}
currentVideoEl = null;
}
function renderContent(item) {
teardownCurrentMedia();
const container = document.getElementById('playerContainer');
container.style.display = 'block';
// Defense in depth: bail to waiting state on missing/malformed item rather
// than fall through every branch and leave a blank container.
const hasRenderableType = item && (
item.widget_id ||
item.mime_type === 'video/youtube' ||
(typeof item.mime_type === 'string' && (item.mime_type.startsWith('video/') || item.mime_type.startsWith('image/')))
);
if (!hasRenderableType) {
showStatus('Waiting for content...');
isPlaying = false;
return;
}
// In wall mode, mount content into a stage that maps the player_rect
// into this device's viewport. playerContainer's overflow:hidden clips
// the parts of the stage outside this device's viewport, so each
// device shows exactly its slice of the wall.
let mount = container;
if (wallConfig) {
const stage = document.createElement('div');
stage.className = 'wall-stage';
styleWallStage(stage);
container.appendChild(stage);
mount = stage;
}
// Followers don't run their own advance timers — the leader's wall:sync
// dictates index transitions. Single-screen and leader behave normally.
const isFollower = !!wallConfig && !wallConfig.is_leader;
const isYoutube = item.mime_type === 'video/youtube';
const isVideo = !isYoutube && item.mime_type?.startsWith('video/');
const isImage = item.mime_type?.startsWith('image/');
const remoteUrl = item.remote_url;
const serverUrl = config.serverUrl;
const src = remoteUrl || `${serverUrl}/uploads/content/${item.filepath}`;
if (layout && layout.zones && layout.zones.length > 1 && !wallConfig) {
renderZones(container, item);
} else {
// Fullscreen / wall-tile
if (isYoutube) {
createYoutubeEmbed(src, item, mount);
} else if (isVideo) {
const video = document.createElement('video');
video.src = src;
video.autoplay = true;
// Followers stay muted unconditionally (leader-only audio); leaders
// start muted only if the user hasn't gestured yet (autoplay policy).
video.muted = isFollower ? true : !userHasInteracted;
// Explicit max volume on the leader so audio is at full level when
// unmute happens (default is 1.0 but make it visible in logs).
if (!isFollower) video.volume = 1.0;
video.playsInline = true;
video.crossOrigin = 'anonymous';
// Wall mode uses object-fit:fill so the source stretches to the
// stage exactly. Cover would re-crop based on each device's stage
// aspect (different innerWidths produce different cover scales),
// which is the original vertical-misalignment bug. Fill keeps the
// vertical mapping uniform across devices that share a viewport
// height. Solo (non-wall) keeps contain to preserve aspect.
video.style.cssText = wallConfig
? 'width:100%;height:100%;object-fit:fill;background:#000'
: 'width:100%;height:100%;object-fit:contain;background:#000';
video.loop = (playlist.length === 1);
video.onended = () => { if (!video.loop && !isFollower) nextItem(); };
video.onerror = (e) => {
console.error('Video error:', src, e);
if (!isFollower) advanceTimer = setTimeout(nextItem, 3000);
};
video.onloadeddata = () => {
console.log('[wall/audio] video loaded file=' + item.filename + ' role=' + (wallConfig ? (wallConfig.is_leader ? 'leader' : 'follower') : 'solo') + ' muted=' + video.muted + ' volume=' + video.volume);
};
// If anything (browser, scripts, the user) tries to unmute a
// follower, snap it back. This is the safety net for the audio
// bug — without it, a single stray unmute call causes echo.
if (isFollower) {
video.addEventListener('volumechange', () => {
if (!video.muted) { video.muted = true; }
});
}
mount.appendChild(video);
currentVideoEl = video;
// Try playing as we set muted above. If the browser blocks
// unmuted autoplay (e.g. no user gesture yet), retry muted.
video.play().then(() => {
console.log('[wall/audio] play() ok muted=' + video.muted + ' volume=' + video.volume);
}).catch((err) => {
console.warn('[wall/audio] play() rejected, falling back to muted: ' + (err?.name || err?.message || err));
video.muted = true;
video.play()
.then(() => console.log('[wall/audio] muted-fallback play() ok'))
.catch((e2) => console.error('[wall/audio] muted-fallback play() also failed: ' + (e2?.name || e2?.message || e2)));
});
// Fallback: force play if not started after 2s
setTimeout(() => { if (video.paused) { video.muted = true; video.play().catch(() => {}); } }, 2000);
} else if (isImage) {
const img = document.createElement('img');
img.src = src;
img.style.cssText = wallConfig
? 'width:100%;height:100%;object-fit:fill'
: 'width:100%;height:100%;object-fit:contain';
img.onerror = () => {
console.error('Image error');
if (!isFollower) advanceTimer = setTimeout(nextItem, 3000);
};
mount.appendChild(img);
// Leader / single screen drives image advance; follower waits for sync
if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 10) * 1000);
} else if (item.widget_id) {
const iframe = document.createElement('iframe');
iframe.src = `${serverUrl}/api/widgets/${item.widget_id}/render`;
iframe.style.cssText = 'width:100%;height:100%;border:none;background:#000';
iframe.allow = 'autoplay; fullscreen';
mount.appendChild(iframe);
if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 30) * 1000);
}
}
}
function renderZones(container, defaultItem) {
// Multi-zone layout
layout.zones.forEach(zone => {
const div = document.createElement('div');
div.className = 'zone';
div.style.cssText = `left:${zone.x_percent}%;top:${zone.y_percent}%;width:${zone.width_percent}%;height:${zone.height_percent}%;z-index:${zone.z_index || 0}`;
// Find assignment for this zone
const assignment = playlist.find(a => a.zone_id === zone.id) || defaultItem;
if (!assignment) return;
const isVideo = assignment.mime_type?.startsWith('video/');
const src = assignment.remote_url || `${config.serverUrl}/uploads/content/${assignment.filepath}`;
const isYoutubeZone = assignment.mime_type === 'video/youtube';
if (zone.zone_type === 'widget' && assignment.widget_id) {
const iframe = document.createElement('iframe');
iframe.src = `${config.serverUrl}/api/widgets/${assignment.widget_id}/render`;
div.appendChild(iframe);
} else if (isYoutubeZone) {
createYoutubeEmbed(src, assignment, div);
} else if (isVideo) {
const video = document.createElement('video');
video.src = src;
video.autoplay = true;
video.muted = (zone.sort_order > 0); // Only first zone has audio
video.loop = (playlist.length === 1);
video.playsInline = true;
video.style.cssText = `width:100%;height:100%;object-fit:${zone.fit_mode || 'cover'}`;
if (!video.loop) video.onended = () => nextItem();
div.appendChild(video);
} else {
const img = document.createElement('img');
img.src = src;
img.style.cssText = `width:100%;height:100%;object-fit:${zone.fit_mode || 'cover'}`;
div.appendChild(img);
if (playlist.length > 1) advanceTimer = setTimeout(nextItem, (assignment.duration_sec || 10) * 1000);
}
container.appendChild(div);
});
}
// ==================== Screenshots ====================
function captureAndSend() {
if (!socket?.connected) return;
const canvas = document.createElement('canvas');
canvas.width = 960;
canvas.height = 540;
const ctx = canvas.getContext('2d');
let captured = false;
try {
const container = document.getElementById('playerContainer');
const video = container?.querySelector('video');
const img = container?.querySelector('img');
// Try video first
if (video && video.readyState >= 2 && video.videoWidth > 0) {
try {
ctx.drawImage(video, 0, 0, 960, 540);
captured = true;
} catch (e) {
console.warn('Video capture failed (CORS?):', e.message);
}
}
// Try image
if (!captured && img && img.complete && img.naturalWidth > 0) {
try {
ctx.drawImage(img, 0, 0, 960, 540);
captured = true;
} catch (e) {
console.warn('Image capture failed:', e.message);
}
}
// Fallback: draw status info
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 Web Player', 480, 230);
ctx.fillStyle = '#94a3b8';
ctx.font = '16px sans-serif';
const item = playlist[currentIndex];
ctx.fillText(item ? `Playing: ${item.filename}` : 'No content', 480, 270);
ctx.fillText(`${config.deviceName || 'Web Player'} | ${new Date().toLocaleTimeString()}`, 480, 310);
}
} catch (e) {
// Even on error, draw something
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, 960, 540);
ctx.fillStyle = '#ef4444';
ctx.font = '16px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Screenshot error: ' + e.message, 480, 270);
}
try {
const dataUrl = canvas.toDataURL('image/jpeg', 0.4);
const base64 = dataUrl.split(',')[1];
if (base64 && base64.length > 100) {
socket.emit('device:screenshot', { device_id: config.deviceId, image_b64: base64 });
console.log('Screenshot sent:', base64.length, 'chars', captured ? '(content)' : '(fallback)');
}
} catch (e) {
console.error('Screenshot encode/send failed:', e);
}
}
function startStreaming() {
stopStreaming();
streamTimer = setInterval(captureAndSend, 1000);
}
function stopStreaming() {
if (streamTimer) { clearInterval(streamTimer); streamTimer = null; }
}
// ==================== UI Helpers ====================
function showStatus(msg) {
document.getElementById('statusOverlay').style.display = 'flex';
document.getElementById('statusText').textContent = msg;
}
function hideStatus() {
document.getElementById('statusOverlay').style.display = 'none';
}
function toggleScreenOff() {
let overlay = document.getElementById('screenOffOverlay');
if (overlay) { overlay.remove(); return; }
overlay = document.createElement('div');
overlay.id = 'screenOffOverlay';
overlay.style.cssText = 'position:fixed;inset:0;background:#000;z-index:9999;cursor:pointer';
overlay.onclick = () => overlay.remove();
document.body.appendChild(overlay);
}
// Create info overlay (toggled by Back button)
const infoDiv = document.createElement('div');
infoDiv.id = 'infoOverlay';
infoDiv.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:800;display:none;flex-direction:column;align-items:center;justify-content:center;color:#f1f5f9;font-family:-apple-system,sans-serif';
infoDiv.innerHTML = `
<h2 style="color:#3b82f6;margin-bottom:16px">${_t('info_title')}</h2>
<div style="font-size:14px;line-height:2;text-align:center;color:#94a3b8" id="infoContent"></div>
<p style="margin-top:24px;font-size:12px;color:#64748b">${_t('info_close_hint')}</p>
`;
infoDiv.onclick = () => { infoDiv.style.display = 'none'; };
document.body.appendChild(infoDiv);
// Escape user-controllable values before injecting into innerHTML — filenames,
// device names, and server URLs are stored on the server and could contain HTML.
const escHtml = (s) => s == null ? '' : String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
// Update info overlay content periodically
setInterval(() => {
const el = document.getElementById('infoContent');
if (!el) return;
const item = playlist[currentIndex];
el.innerHTML = `
${_t('info_device_id')}: ${escHtml(config.deviceId?.slice(0, 8) || _t('info_na'))}...<br>
${_t('info_device_name')}: ${escHtml(config.deviceName || _t('info_na'))}<br>
${_t('info_server')}: ${escHtml(config.serverUrl || _t('info_na'))}<br>
${_t('info_status')}: ${socket?.connected ? `<span style="color:#22c55e">${_t('info_connected')}</span>` : `<span style="color:#ef4444">${_t('info_disconnected')}</span>`}<br>
${_t('info_now_playing')}: ${escHtml(item?.filename || _t('info_nothing'))} (${currentIndex + 1}/${playlist.length})<br>
${_t('info_resolution')}: ${screen.width}x${screen.height}<br>
${_t('info_uptime')}: ${Math.floor(performance.now() / 60000)}m<br>
${_t('info_platform')}: ${escHtml(navigator.platform)}<br>
${_t('info_cache')}: ${_t('info_sw')} ${navigator.serviceWorker?.controller ? `<span style="color:#22c55e">${_t('info_active')}</span>` : _t('info_inactive')}
`;
}, 2000);
// ==================== Fullscreen ====================
// Only attempt fullscreen on genuine user clicks. Synthetic clicks dispatched
// by the remote-control feature (touch forwarding from the dashboard) are not
// trusted by the browser and requestFullscreen() rejects with a "Permissions
// check failed" / "API can only be initiated by a user gesture" error every
// time, spamming the console.
document.addEventListener('click', (e) => {
if (!e.isTrusted) return;
if (!document.fullscreenElement && config.paired) {
document.documentElement.requestFullscreen?.() ||
document.documentElement.webkitRequestFullscreen?.();
}
});
// Prevent sleep/screen saver
let wakeLock = null;
async function requestWakeLock() {
try {
if ('wakeLock' in navigator) {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => { setTimeout(requestWakeLock, 1000); });
}
} catch {}
}
requestWakeLock();
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') requestWakeLock(); });
// Register service worker for offline content caching
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/player/sw.js').then(reg => {
console.log('Service Worker registered');
// When a new SW activates, reload so the fresh code takes effect immediately
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated' && navigator.serviceWorker.controller) {
console.log('New Service Worker activated — reloading for fresh code');
location.reload();
}
});
}
});
}, (err) => console.warn('SW registration failed:', err));
}
// ==================== Keyboard shortcuts ====================
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Reset config and go back to setup
if (confirm('Reset player and return to setup?')) {
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(PLAYLIST_CACHE_KEY);
location.reload();
}
}
if (e.key === 'f' || e.key === 'F11') {
e.preventDefault();
if (document.fullscreenElement) document.exitFullscreen();
else document.documentElement.requestFullscreen();
}
});
</script>
</body>
</html>