mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
- Add-Display modal in index.html: marked translatable elements with data-i18n / data-i18n-placeholder / data-i18n-html attributes - app.js: translateStaticDom() walks data-i18n* on init and on every language-changed event so static HTML stays in sync - server/player/index.html: standalone player gets its own inline PLAYER_I18N table (en/es/fr/de/pt) with a tiny _t() helper. Reads rd_lang from localStorage (set by dashboard) so the player picks up the same language. Translates info overlay, setup screen, and status messages. - 1018 keys total in dashboard locales, parity 100%. This completes the wiring; Android resources are next. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1144 lines
51 KiB
HTML
1144 lines
51 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; }
|
||
|
||
/* 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;
|
||
// 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;
|
||
|
||
// Track user interaction for autoplay policy
|
||
['click', 'touchstart', 'keydown'].forEach(evt => {
|
||
document.addEventListener(evt, () => {
|
||
userHasInteracted = true;
|
||
// HTML5 video: setting muted=false on a video that's been muted-autoplaying
|
||
// causes the browser to pause it as a side effect. We have a real user
|
||
// gesture here, so play() should succeed — but if it doesn't, fall back to
|
||
// muted playback rather than leaving a black/paused screen.
|
||
const video = document.querySelector('#playerContainer video');
|
||
if (video && video.muted) {
|
||
video.muted = false;
|
||
video.play()
|
||
.then(() => console.log('Unmuted video after user interaction'))
|
||
.catch(err => {
|
||
console.warn('Unmuted play() rejected, falling back to muted:', err?.message || err);
|
||
video.muted = true;
|
||
video.play().catch(e => console.warn('Muted fallback play() failed:', e?.message || e));
|
||
});
|
||
}
|
||
// 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 ====================
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
// ==================== 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); }
|
||
// 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 = () => {
|
||
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);
|
||
};
|
||
|
||
// ==================== 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,
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
socket.on('device:content-delete', (data) => {
|
||
playlist = playlist.filter(p => p.content_id !== data.content_id);
|
||
savePlaylistCache(playlist);
|
||
if (playlist.length === 0) showStatus('Waiting for content...');
|
||
});
|
||
|
||
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':
|
||
if (video) { 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
|
||
if (video) { 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);
|
||
}
|
||
|
||
// ==================== 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';
|
||
}
|
||
}
|
||
|
||
layout = data.layout || null;
|
||
|
||
if (newFp === oldFp && playlist.length > 0) {
|
||
console.log('Playlist unchanged');
|
||
return;
|
||
}
|
||
|
||
console.log('Playlist changed, updating');
|
||
playlist = newItems;
|
||
savePlaylistCache(playlist);
|
||
|
||
if (playlist.length === 0) {
|
||
showStatus('Waiting for content...');
|
||
isPlaying = false;
|
||
return;
|
||
}
|
||
|
||
document.getElementById('setupScreen').style.display = 'none';
|
||
|
||
// Always restart playback when content changes
|
||
currentIndex = 0;
|
||
isPlaying = true;
|
||
playCurrentItem();
|
||
}
|
||
|
||
function playCurrentItem() {
|
||
if (currentIndex < 0 || currentIndex >= playlist.length) {
|
||
currentIndex = 0;
|
||
if (playlist.length === 0) { showStatus('Waiting for content...'); return; }
|
||
}
|
||
|
||
hideStatus();
|
||
const item = playlist[currentIndex];
|
||
console.log('Playing:', item.filename, `(${currentIndex + 1}/${playlist.length})`);
|
||
|
||
// Send play event
|
||
if (socket?.connected) {
|
||
socket.emit('device:play-event', {
|
||
device_id: config.deviceId,
|
||
event: 'play_start',
|
||
content_id: item.content_id,
|
||
content_name: item.filename,
|
||
});
|
||
}
|
||
|
||
renderContent(item);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function renderContent(item) {
|
||
// Clear any pending advance timer from previous content (image/widget duration timers)
|
||
if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }
|
||
|
||
const container = document.getElementById('playerContainer');
|
||
container.style.display = 'block';
|
||
container.innerHTML = '';
|
||
|
||
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) {
|
||
renderZones(container, item);
|
||
} else {
|
||
// Fullscreen
|
||
if (isYoutube) {
|
||
createYoutubeEmbed(src, item, container);
|
||
} else if (isVideo) {
|
||
const video = document.createElement('video');
|
||
video.src = src;
|
||
video.autoplay = true;
|
||
video.muted = !userHasInteracted; // Unmuted if user has interacted
|
||
video.playsInline = true;
|
||
video.crossOrigin = 'anonymous';
|
||
video.style.cssText = 'width:100%;height:100%;object-fit:contain;background:#000';
|
||
video.loop = (playlist.length === 1);
|
||
video.onended = () => { if (!video.loop) nextItem(); };
|
||
video.onerror = (e) => { console.error('Video error:', src, e); advanceTimer = setTimeout(nextItem, 3000); };
|
||
video.onloadeddata = () => {
|
||
console.log('Video loaded:', item.filename, 'muted:', video.muted);
|
||
};
|
||
container.appendChild(video);
|
||
// Try playing unmuted, fall back to muted
|
||
video.play().catch(() => { video.muted = true; video.play().catch(() => {}); });
|
||
// 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 = 'width:100%;height:100%;object-fit:contain';
|
||
img.onerror = () => { console.error('Image error'); advanceTimer = setTimeout(nextItem, 3000); };
|
||
container.appendChild(img);
|
||
// Auto advance for images
|
||
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';
|
||
container.appendChild(iframe);
|
||
// Auto advance for widgets
|
||
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>
|