mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
1566 lines
72 KiB
HTML
1566 lines
72 KiB
HTML
<!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 d’appairage', 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 l’appareil', info_device_name: 'Nom de l’appareil', 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">🔇</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
|
||
function connectBtnFunc() {
|
||
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})`;
|
||
|
||
const 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">⚠</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||
|
||
// 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>
|