screentinker/server/player/index.html
ScreenTinker c105a5941e Security: fix IDORs, XSS, rate limits, SSRF validation
HIGH 1 (teams IDOR): POST/DELETE /api/teams/:id/devices now require the
caller to own the device before assigning or detaching it. Without this
check, any team member could pull any device into their team via UUID
guess and gain remote-control access.

HIGH 2 (schedules IDOR): PUT /api/schedules/:id now re-verifies
ownership of every changed target field — device_id, group_id,
content_id, widget_id, layout_id, playlist_id. Previously only the
schedule owner was checked, letting users fire arbitrary content on
victim devices via update.

HIGH 3 (filename XSS): file.originalname captured by multer bypassed
sanitizeBody. New safeFilename() wraps every INSERT path (multipart
upload, remote URL, YouTube). Frontend sinks now go through esc() in
content-library.js, device-detail.js, video-wall.js. Web player gets
an inline escHtml helper for its info overlay where filenames, device
name, and serverUrl land in innerHTML.

HIGH 4 (kiosk public XSS): config.idleTimeout is now coerced via the
existing safeNumber() helper at both interpolation sites. A crafted
value with a newline can no longer escape the JS line comment to
inject arbitrary code into the public render endpoint.

HIGH 5 (folder DoS): POST /api/folders enforces a per-user cap of 100
folders (429 on overflow). Superadmin exempt.

MED 1 (SSRF): ImageLoader.decodeUrl rejects any URL scheme other than
http(s) so a malicious remote_url can't read local files via file://.
On the server, validateRemoteUrl() is extracted and now also runs on
PUT /api/content/:id remote_url updates — previously the SSRF check
only fired on POST.

MED 2 (fingerprint takeover): the WS device:register fingerprint
reclaim path now rejects takeover while the target device is online or
within 24h of its last heartbeat. A leaked fingerprint can no longer
hijack an active display.

MED 3 (npm audit): bumped uuid 9.x -> 14.0.0 (v3/v5/v6 buffer bounds
CVE; we only use v4 so not exploitable, but clears the audit). path-
to-regexp resolved to 0.1.13 via npm audit fix. 0 vulns remaining.

MED 4 (folder admin consistency): ownedFolder() and the content.js
folder_id move check now both treat only superadmin as privileged,
matching GET /api/folders. Previously a plain "admin" could rename
or delete folders they couldn't see, and could move content into
folders they couldn't list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:37:18 -05:00

1030 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;
// 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();
}
// If audio was previously unlocked (user tapped in a prior session), skip the overlay
if (userHasInteracted) {
console.log('Audio previously unlocked, skipping tap overlay');
unlockAudio();
connect(config.serverUrl);
} else {
// Show tap-to-start overlay to unlock audio on auto-reconnect
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">&#9888;</div>
<h2 style="color:#f59e0b;margin-bottom:8px">${data.message || 'Account Suspended'}</h2>
<p style="color:#94a3b8;font-size:16px;margin-bottom:24px">${data.detail || 'Please upgrade your plan.'}</p>
<p style="color:#64748b;font-size:13px">Visit your dashboard to manage your subscription</p>
</div>
`;
return;
}
const newItems = data.assignments || [];
// Build fingerprint from id + url + filename to detect any content change
const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}`).join(',');
const newFp = fingerprint(newItems);
const oldFp = fingerprint(playlist);
// Apply orientation
if (data.orientation) {
const rotations = { 'landscape': '0deg', 'portrait': '90deg', 'landscape-flipped': '180deg', 'portrait-flipped': '270deg' };
document.getElementById('playerContainer').style.transform = `rotate(${rotations[data.orientation] || '0deg'})`;
if (data.orientation.includes('portrait')) {
document.getElementById('playerContainer').style.transformOrigin = 'center center';
document.getElementById('playerContainer').style.width = '100vh';
document.getElementById('playerContainer').style.height = '100vw';
}
}
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
let ytApiReady = false;
let ytApiCallbacks = [];
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 = [];
};
}
}
let activeYtPlayer = null;
let ytGeneration = 0; // Incremented on each new YouTube embed to ignore stale callbacks
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
// Update info overlay content periodically
setInterval(() => {
const el = document.getElementById('infoContent');
if (!el) return;
const item = playlist[currentIndex];
el.innerHTML = `
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>