mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Browser autoplay policy is per-document — a previous session's localStorage flag does not grant the new page autoplay rights. The 'audio previously unlocked, skipping tap overlay' branch was racing with YouTube's autoplay block, leaving the player stuck on a paused embed. Removed the skip-overlay optimization. The existing 5s auto-dismiss + muted-connect fallback still handles unattended kiosks, and a real user only needs to tap once per cold load to get audio. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1032 lines
42 KiB
HTML
1032 lines
42 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; }
|
|
.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">Connecting...</p>
|
|
</div>
|
|
|
|
<script src="/socket.io/socket.io.js"></script>
|
|
<script>
|
|
// ==================== 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 = {};
|
|
let userHasInteracted = !!localStorage.getItem('rd_audio_unlocked');
|
|
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;
|
|
localStorage.setItem('rd_audio_unlocked', '1');
|
|
// Try to unmute any playing HTML5 video
|
|
const video = document.querySelector('#playerContainer video');
|
|
if (video && video.muted) {
|
|
video.muted = false;
|
|
video.play().catch(() => {});
|
|
console.log('Unmuted video after user interaction');
|
|
}
|
|
// Unmute YouTube player if active
|
|
if (activeYtPlayer && typeof activeYtPlayer.unMute === 'function') {
|
|
try { activeYtPlayer.unMute(); 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('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('Connecting (audio 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;
|
|
localStorage.setItem('rd_audio_unlocked', '1');
|
|
// 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 video
|
|
document.querySelectorAll('video').forEach(v => { v.muted = false; });
|
|
}
|
|
|
|
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();
|
|
};
|
|
container.style.position = 'relative';
|
|
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">ScreenTinker Web Player</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">Press Back again or click to close</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 = `
|
|
Device ID: ${escHtml(config.deviceId?.slice(0, 8) || 'N/A')}...<br>
|
|
Device Name: ${escHtml(config.deviceName || 'N/A')}<br>
|
|
Server: ${escHtml(config.serverUrl || 'N/A')}<br>
|
|
Status: ${socket?.connected ? '<span style="color:#22c55e">Connected</span>' : '<span style="color:#ef4444">Disconnected</span>'}<br>
|
|
Now Playing: ${escHtml(item?.filename || 'Nothing')} (${currentIndex + 1}/${playlist.length})<br>
|
|
Resolution: ${screen.width}x${screen.height}<br>
|
|
Uptime: ${Math.floor(performance.now() / 60000)}m<br>
|
|
Platform: ${escHtml(navigator.platform)}<br>
|
|
Cache: Service Worker ${navigator.serviceWorker?.controller ? '<span style="color:#22c55e">Active</span>' : 'Inactive'}
|
|
`;
|
|
}, 2000);
|
|
|
|
// ==================== Fullscreen ====================
|
|
document.addEventListener('click', () => {
|
|
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>
|