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 { // Retry once on rare upsert races (409). for (let attempt = 0; attempt < 2; attempt += 1) { const res = 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, }); if (res.ok) return true; if (res.status === 409) continue; if (res.status === 401) return false; return true; // best-effort; don't break playback on other errors } return true; } catch (_) { // best-effort return true; } } 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; let lastSaveStatus = '—'; let seekSaveTimer = null; function getSnapshot() { const t = Number(videoEl.currentTime); const d = Number(videoEl.duration); return { // Use whole seconds to keep saved values stable. time: Number.isFinite(t) && t >= 0 ? Math.floor(t) : 0, duration: Number.isFinite(d) && d > 0 ? Math.floor(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 (_) {} lastSaveStatus = `Reprise à ${formatClock(clamped)}.`; setProgressStatus(lastSaveStatus); } else { lastSaveStatus = 'Lecture depuis le début.'; setProgressStatus(lastSaveStatus); } } async function persist({ keepalive, force } = {}) { // Throttle to avoid spamming the API const now = Date.now(); if (!force && !keepalive && now - lastSavedAt < 1500) return; const snap = getSnapshot(); if (!Number.isFinite(snap.time)) return; // Only save if it meaningfully changed (>= 1s) if (!force && 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 (_) {} setProgressStatus('Sauvegarde…'); const ok = await savePlaybackProgress(movieId, token, { ...snap, keepalive: !!keepalive }); if (ok === false) { // Token expired mid-playback; stop spamming requests and prompt user. stopInterval(); try { localStorage.removeItem('auth_token'); } catch (_) {} lastSaveStatus = 'Connexion requise pour sauvegarder.'; setProgressStatus(lastSaveStatus); showAlertVariant('Session expirée. Reconnecte-toi pour continuer à sauvegarder la progression.', 'warning'); return; } lastSaveStatus = `Sauvegardé à ${formatClock(snap.time)}.`; setProgressStatus(lastSaveStatus); } function startInterval() { if (intervalId) return; // Safety-net; primary saving happens via timeupdate throttling. intervalId = window.setInterval(() => { if (videoEl.paused || videoEl.seeking) return; void persist(); }, 8000); } function stopInterval() { if (!intervalId) return; window.clearInterval(intervalId); intervalId = null; } const onPlay = () => startInterval(); const onPause = () => { stopInterval(); void persist({ force: true }); }; const onSeeking = () => { // While scrubbing, browsers fire many events; debounce a forced save so it lands quickly. if (seekSaveTimer) window.clearTimeout(seekSaveTimer); seekSaveTimer = window.setTimeout(() => { void persist({ force: true }); }, 400); }; const onSeeked = () => { void persist({ force: true }); }; const onEnded = () => { stopInterval(); void persist(); }; const onError = () => { stopInterval(); }; const onTimeUpdate = () => { if (videoEl.paused || videoEl.seeking) return; void persist(); }; const onPageHide = () => { void persist({ keepalive: true, force: true }); }; const onVisibility = () => { if (document.visibilityState === 'hidden') void persist({ keepalive: true, force: true }); }; videoEl.addEventListener('loadedmetadata', restoreProgressOnce, { once: true }); videoEl.addEventListener('play', onPlay); videoEl.addEventListener('pause', onPause); videoEl.addEventListener('seeking', onSeeking); videoEl.addEventListener('seeked', onSeeked); videoEl.addEventListener('ended', onEnded); videoEl.addEventListener('error', onError); videoEl.addEventListener('timeupdate', onTimeUpdate); window.addEventListener('pagehide', onPageHide); document.addEventListener('visibilitychange', onVisibility); // If metadata is already available, try immediately. if (videoEl.readyState >= 1) void restoreProgressOnce(); return () => { stopInterval(); if (seekSaveTimer) window.clearTimeout(seekSaveTimer); videoEl.removeEventListener('play', onPlay); videoEl.removeEventListener('pause', onPause); videoEl.removeEventListener('seeking', onSeeking); videoEl.removeEventListener('seeked', onSeeked); videoEl.removeEventListener('ended', onEnded); videoEl.removeEventListener('error', onError); videoEl.removeEventListener('timeupdate', onTimeUpdate); window.removeEventListener('pagehide', onPageHide); document.removeEventListener('visibilitychange', onVisibility); if (lastSaveStatus) setProgressStatus(lastSaveStatus); }; } 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 setProgressStatus(text) { const el = document.getElementById('progress-status'); if (!el) return; el.textContent = text || '—'; } function formatClock(seconds) { const s = Math.max(0, Math.floor(Number(seconds) || 0)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; return `${m}:${String(sec).padStart(2, '0')}`; } 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(' { try { const u = new URL(sessionUrl, window.location.origin); return u.origin !== window.location.origin; } catch (_) { return false; } })(); try { await fetch(sessionUrl, { method: 'POST', headers: token ? { 'Authorization': `Bearer ${token}` } : undefined, ...(isCrossOrigin ? { credentials: 'include' } : {}), }); } catch (_) { // best-effort } } async function playVodLink(vodLink) { const raw = normalizeVodLink(vodLink); if (!raw) { showAlert('No VOD link configured for this movie.'); setSourceLabel('—'); return; } // "Open original" button setOpenOriginal(raw); // Prefer known embed providers const yt = toYoutubeEmbed(raw); if (yt) { setSourceLabel('YouTube (embed)'); showEmbedPlayer(yt); return; } const vimeo = toVimeoEmbed(raw); if (vimeo) { setSourceLabel('Vimeo (embed)'); showEmbedPlayer(vimeo); return; } // App/Python protected video endpoint (no file extension, but it's still a direct