function decodeJwt(token) { return JSON.parse(atob(token.split('.')[1])); } function safeJsonParse(v) { try { return JSON.parse(v); } catch (_) { return null; } } function progressStorageKey(userId, movieId) { return `playback_progress:${String(userId || '')}:${String(movieId || '')}`; } async function fetchPlaybackProgress(movieId, token) { try { const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/progress`, { method: 'GET', headers: token ? { 'Authorization': `Bearer ${token}` } : undefined, }); if (!res.ok) return null; const data = await res.json().catch(() => null); if (!data) return null; const time = Number(data.time); const duration = data.duration === null || data.duration === undefined ? null : Number(data.duration); return { time: Number.isFinite(time) && time >= 0 ? time : 0, duration: duration !== null && Number.isFinite(duration) && duration > 0 ? duration : null, }; } catch (_) { return null; } } async function savePlaybackProgress(movieId, token, { time, duration, keepalive } = {}) { try { await fetch(`/api/movies/${encodeURIComponent(movieId)}/progress`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}), }, body: JSON.stringify({ time, duration }), keepalive: !!keepalive, }); } catch (_) { // best-effort } } function setupVideoProgress({ videoEl, movieId, token, userId }) { if (!videoEl || !movieId || !token || !userId) return () => {}; const key = progressStorageKey(userId, movieId); let restored = false; let intervalId = null; let lastSavedAt = 0; let lastSavedTime = -1; function getSnapshot() { const t = Number(videoEl.currentTime); const d = Number(videoEl.duration); return { time: Number.isFinite(t) && t >= 0 ? t : 0, duration: Number.isFinite(d) && d > 0 ? d : null, }; } async function restoreProgressOnce() { if (restored) return; restored = true; // Prefer server progress, fall back to localStorage if offline. const server = await fetchPlaybackProgress(movieId, token); let targetTime = server?.time ?? 0; if (!Number.isFinite(targetTime) || targetTime < 0) targetTime = 0; if (targetTime <= 1) { const local = safeJsonParse(localStorage.getItem(key)); const lt = Number(local?.time); if (Number.isFinite(lt) && lt > 1) targetTime = lt; } const dur = Number(videoEl.duration); if (!Number.isFinite(dur) || dur <= 0) return; // Clamp to avoid seeking beyond the end. const clamped = Math.min(Math.max(targetTime, 0), Math.max(0, dur - 3)); if (clamped > 1) { try { videoEl.currentTime = clamped; } catch (_) {} } } async function persist({ keepalive } = {}) { // Throttle to avoid spamming the API const now = Date.now(); if (!keepalive && now - lastSavedAt < 2000) return; const snap = getSnapshot(); if (!Number.isFinite(snap.time)) return; // Only save if it meaningfully changed (>= 1s) if (lastSavedTime >= 0 && Math.abs(snap.time - lastSavedTime) < 1) return; lastSavedAt = now; lastSavedTime = snap.time; try { localStorage.setItem(key, JSON.stringify({ time: snap.time, duration: snap.duration, at: now })); } catch (_) {} await savePlaybackProgress(movieId, token, { ...snap, keepalive: !!keepalive }); } function startInterval() { if (intervalId) return; intervalId = window.setInterval(() => { void persist(); }, 10000); } function stopInterval() { if (!intervalId) return; window.clearInterval(intervalId); intervalId = null; } const onPlay = () => startInterval(); const onPause = () => { stopInterval(); void persist(); }; const onSeeked = () => { void persist(); }; const onEnded = () => { stopInterval(); void persist(); }; const onError = () => { stopInterval(); }; const onPageHide = () => { void persist({ keepalive: true }); }; const onVisibility = () => { if (document.visibilityState === 'hidden') void persist({ keepalive: true }); }; videoEl.addEventListener('loadedmetadata', restoreProgressOnce, { once: true }); videoEl.addEventListener('play', onPlay); videoEl.addEventListener('pause', onPause); videoEl.addEventListener('seeked', onSeeked); videoEl.addEventListener('ended', onEnded); videoEl.addEventListener('error', onError); window.addEventListener('pagehide', onPageHide); document.addEventListener('visibilitychange', onVisibility); // If metadata is already available, try immediately. if (videoEl.readyState >= 1) void restoreProgressOnce(); return () => { stopInterval(); videoEl.removeEventListener('play', onPlay); videoEl.removeEventListener('pause', onPause); videoEl.removeEventListener('seeked', onSeeked); videoEl.removeEventListener('ended', onEnded); videoEl.removeEventListener('error', onError); window.removeEventListener('pagehide', onPageHide); document.removeEventListener('visibilitychange', onVisibility); }; } function getQueryParam(name) { const url = new URL(window.location.href); return url.searchParams.get(name); } function showAlert(message) { const el = document.getElementById('alert'); if (!el) return; el.textContent = message; // Ensure error styling (danger) when using the simple API. el.classList.remove('alert-primary', 'alert-secondary', 'alert-success', 'alert-danger', 'alert-warning', 'alert-info', 'alert-light', 'alert-dark'); el.classList.add('alert-danger'); el.classList.remove('d-none'); } function showAlertVariant(message, variant) { const el = document.getElementById('alert'); if (!el) return; el.textContent = message; el.classList.remove('d-none'); // Reset any prior bootstrap alert-* variants el.classList.remove('alert-primary', 'alert-secondary', 'alert-success', 'alert-danger', 'alert-warning', 'alert-info', 'alert-light', 'alert-dark'); const v = String(variant || 'danger').trim().toLowerCase(); el.classList.add(`alert-${v || 'danger'}`); } function setSourceLabel(text) { const el = document.getElementById('source-label'); if (!el) return; el.textContent = text || '—'; el.title = text || ''; } function setHeader(movie) { const titleEl = document.getElementById('movie-title'); const categoryEl = document.getElementById('movie-category'); const ratingEl = document.getElementById('movie-rating'); if (titleEl) titleEl.textContent = movie?.title || 'Player'; if (categoryEl) { const cat = movie?.category ? String(movie.category) : ''; categoryEl.textContent = cat ? `${cat} · ` : ''; } if (ratingEl) { const r = movie?.rating ? String(movie.rating) : ''; ratingEl.textContent = r ? `⭐ ${r}` : ''; } document.title = movie?.title ? `Player - ${movie.title}` : 'Player'; } function normalizeVodLink(raw) { const v = String(raw || '').trim(); if (!v) return null; // Support pasted iframe embed code (extract src) if (v.toLowerCase().includes(' { // If the browser can't play it (or CORS blocks), fall back to iframe. setSourceLabel('Embed (fallback)'); showEmbedPlayer(raw); }, { once: true }); try { videoEl.load(); } catch (_) {} return; } // HLS (.m3u8) if (looksLikeHls(raw)) { setSourceLabel('HLS stream (.m3u8)'); const videoEl = showVideoPlayer(); if (!videoEl) return; // Native HLS (Safari) first if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { videoEl.src = raw; try { videoEl.load(); } catch (_) {} return; } // hls.js fallback (if supported) if (window.Hls && window.Hls.isSupported()) { const hls = new window.Hls({ enableWorker: true, }); hls.loadSource(raw); hls.attachMedia(videoEl); hls.on(window.Hls.Events.ERROR, function (_evt, data) { if (data?.fatal) { try { hls.destroy(); } catch (_) {} setSourceLabel('Embed (fallback)'); showEmbedPlayer(raw); } }); return; } // Last resort setSourceLabel('Embed (fallback)'); showEmbedPlayer(raw); return; } // Default: treat as embeddable URL setSourceLabel('Embed'); showEmbedPlayer(raw); } function cleanUrlValue(v) { const s = typeof v === 'string' ? v.trim() : ''; return s ? s : null; } function resolveMovieSource(movie) { const mode = String(movie?.player_mode || 'auto').toLowerCase(); const legacy = cleanUrlValue(movie?.vod_link); const video = cleanUrlValue(movie?.video_src); const embed = cleanUrlValue(movie?.embed_src); const serverFile = cleanUrlValue(movie?.video_file); // Legacy should never embed in the player (open in new tab only). // So we only pick legacy if there is no other option, and mark it. let picked = null; const serverVideoUrl = serverFile ? `/api/video/${encodeURIComponent(String(movie?._id || ''))}` : null; if (mode === 'video') picked = video || serverVideoUrl || null; else if (mode === 'embed') picked = embed || null; else picked = video || serverVideoUrl || embed || null; // auto if (picked) return { src: picked, isLegacy: false }; if (legacy) return { src: legacy, isLegacy: true }; return { src: null, isLegacy: false }; } window.onload = async function () { // Basic auth UX: require a valid local token (like the rest of the app). const token = localStorage.getItem('auth_token'); if (!token) { window.location.href = '/'; return; } let decoded = null; try { decoded = decodeJwt(token); const currentTime = Math.floor(Date.now() / 1000); if (decoded.exp && decoded.exp < currentTime) { localStorage.removeItem('auth_token'); window.location.href = '/'; return; } if (decoded.mustChangePassword) { window.location.href = '/change-password.html'; return; } const nameEl = document.getElementById('user-name'); if (nameEl) nameEl.textContent = decoded.name || ''; if (decoded.admin) { const adminLink = document.getElementById('admin-control-link'); if (adminLink) adminLink.classList.remove('d-none'); } const logoffBtn = document.getElementById('log-off'); if (logoffBtn) { logoffBtn.addEventListener('click', function () { localStorage.removeItem('auth_token'); window.location.href = '/'; }); } } catch (_) { localStorage.removeItem('auth_token'); window.location.href = '/'; return; } const id = getQueryParam('id'); if (!id) { showAlert('Missing movie id.'); return; } try { const res = await fetch(`/api/movies/${encodeURIComponent(id)}`, { method: 'GET', headers: token ? { 'Authorization': `Bearer ${token}` } : undefined, }); const movie = await res.json().catch(() => ({})); if (!res.ok) { showAlert(movie?.message || movie?.error || `Failed to load movie (${res.status}).`); return; } setHeader(movie); // Allow