function decodeJwt(token) { const parts = String(token || '').split('.'); if (parts.length < 2) throw new Error('Invalid token'); // JWT uses base64url (not base64) const b64url = String(parts[1] || '').replace(/-/g, '+').replace(/_/g, '/'); const padded = b64url + '='.repeat((4 - (b64url.length % 4)) % 4); return JSON.parse(atob(padded)); } function createPageLoader(options = {}) { const title = String(options.title || 'Chargement…'); let progress = 0; let removed = false; let navResizeObserver = null; // Reuse a preloaded overlay if present (prevents initial "clear" flash). const preloadedOverlay = document.getElementById('page-loader-overlay'); const overlay = preloadedOverlay || document.createElement('div'); if (!preloadedOverlay) { overlay.className = 'page-loader-overlay'; overlay.setAttribute('role', 'status'); overlay.setAttribute('aria-live', 'polite'); overlay.setAttribute('aria-busy', 'true'); overlay.innerHTML = `
`; } else { const label = overlay.querySelector('.visually-hidden'); if (label) label.textContent = title; } const barEl = () => overlay.querySelector('#page-loader-bar'); function clampPct(p) { const n = Number(p); if (!Number.isFinite(n)) return 0; return Math.max(0, Math.min(100, n)); } function setProgress(p) { progress = clampPct(p); const bar = barEl(); if (bar) bar.style.width = `${progress}%`; } function getNavHeightPx() { const nav = document.querySelector('nav.navbar'); if (!nav) return 0; const rect = nav.getBoundingClientRect(); const h = Number(rect?.height) || 0; return h > 0 ? Math.round(h) : 0; } function updateOverlayTopOffset() { overlay.style.setProperty('--page-loader-top', `${getNavHeightPx()}px`); } function ensureMounted() { if (removed) return; updateOverlayTopOffset(); document.body.classList.add('page-loading'); if (!overlay.isConnected) document.body.appendChild(overlay); if (!navResizeObserver) { const nav = document.querySelector('nav.navbar'); if (nav && typeof ResizeObserver !== 'undefined') { try { navResizeObserver = new ResizeObserver(() => updateOverlayTopOffset()); navResizeObserver.observe(nav); } catch (_) { navResizeObserver = null; } } } } function hideAndRemoveSoon() { if (removed) return; overlay.classList.add('page-loader-hide'); overlay.setAttribute('aria-busy', 'false'); document.body.classList.remove('page-loading'); window.setTimeout(() => { removed = true; if (navResizeObserver) { try { navResizeObserver.disconnect(); } catch (_) {} navResizeObserver = null; } try { overlay.remove(); } catch (_) {} }, 220); } function done() { setProgress(100); hideAndRemoveSoon(); } function fail() { setProgress(Math.max(progress, 95)); window.setTimeout(() => hideAndRemoveSoon(), 200); } ensureMounted(); setProgress(8); return { setProgress, done, fail }; } function safeJsonParse(v) { try { return JSON.parse(v); } catch (_) { return null; } } function progressStorageKey(userId, movieId) { return `playback_progress:${String(userId || '')}:${String(movieId || '')}`; } function normalizeUserId(raw) { // Expect a Mongo ObjectId string in the token payload. const v = raw?.id; if (typeof v === 'string' && /^[a-f0-9]{24}$/i.test(v)) return v; const s = v && typeof v === 'object' && typeof v.toString === 'function' ? v.toString() : String(v || ''); return /^[a-f0-9]{24}$/i.test(s) ? s : null; } async function fetchActiveOscarYear() { try { const res = await fetch('/api/settings/year', { method: 'GET' }); if (!res.ok) return null; const data = await res.json().catch(() => null); const year = Number(data?.year); return Number.isInteger(year) ? year : null; } catch (_) { return null; } } async function fetchPlayerAdminStatusUi() { try { const res = await fetch('/api/settings/player-admin-status-ui', { method: 'GET' }); if (!res.ok) return { showSource: true, showProgress: true }; const data = await res.json().catch(() => ({})); return { showSource: data?.showSource === undefined ? true : !!data.showSource, showProgress: data?.showProgress === undefined ? true : !!data.showProgress, }; } catch (_) { return { showSource: true, showProgress: true }; } } 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, imdbId, keepalive } = {}) { try { // Retry once on rare upsert races (409). for (let attempt = 0; attempt < 2; attempt += 1) { const payload = { time }; // Don't send duration when unknown; avoids wiping server-side duration. if (typeof duration === 'number' && Number.isFinite(duration) && duration > 0) { payload.duration = duration; } const safeImdb = typeof imdbId === 'string' ? imdbId.trim() : ''; if (safeImdb) payload.imdb_id = safeImdb; const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/progress`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}), }, body: JSON.stringify(payload), 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, imdbId }) { if (!videoEl || !movieId || !token || !userId) return () => {}; const key = progressStorageKey(userId, movieId); let restored = false; let restoreInProgress = false; let pendingRestoreTime = null; // number | null let restoreServerPromise = null; let intervalId = null; let lastSavedAt = 0; let lastSavedTime = -1; let lastSaveStatus = '—'; let seekSaveTimer = null; let savedUiTimer = null; let startedOnce = false; let startSaveTimer = 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 tryRestoreProgress() { if (restored) return true; const dur = Number(videoEl.duration); // Don't mark restored until we have a usable duration: some sources report NaN/Infinity first. if (!Number.isFinite(dur) || dur <= 0 || dur === Infinity) return false; // Prefer server progress, fall back to localStorage if offline. if (!restoreServerPromise) restoreServerPromise = fetchPlaybackProgress(movieId, token); const server = await restoreServerPromise; 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; } // Clamp to avoid seeking beyond the end. const clamped = Math.min(Math.max(targetTime, 0), Math.max(0, dur - 3)); restored = true; if (clamped > 1) { // Block any progress saves until the seek completes; otherwise we can overwrite // server progress with an early "0s" save during reload/startup. pendingRestoreTime = clamped; restoreInProgress = true; try { videoEl.currentTime = clamped; } catch (_) {} lastSaveStatus = `Reprise · ${formatClock(clamped)}`; setProgressUi({ state: 'info', text: lastSaveStatus, showText: true, ariaText: `Reprise à ${formatClock(clamped)}` }); } else { pendingRestoreTime = null; restoreInProgress = false; lastSaveStatus = 'Début'; setProgressUi({ state: 'info', text: 'Début', showText: true, ariaText: 'Lecture depuis le début' }); } return true; } function scheduleRestore() { if (!restored) setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' }); void tryRestoreProgress(); } async function persist({ keepalive, force } = {}) { // Never persist until the resume logic finished; otherwise the first "0s" timeupdate // on reload can overwrite the user's saved progress on the server. if (!restored) { void tryRestoreProgress(); return; } if (restoreInProgress) return; // 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; if (pendingRestoreTime !== null) { // Some browsers may not fire seeked reliably. Only start saving once we // observe that currentTime actually reached the restored position. if (snap.time >= Math.max(0, Math.floor(pendingRestoreTime) - 1)) { pendingRestoreTime = null; } else { 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 (_) {} // Icon-only saving indicator (avoid noisy text updates). setProgressUi({ state: 'saving', text: '', showText: false, ariaText: 'Sauvegarde…' }); const ok = await savePlaybackProgress(movieId, token, { ...snap, imdbId, 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.'; setProgressUi({ state: 'warning', text: 'Connexion', showText: true, ariaText: lastSaveStatus }); showAlertVariant('Session expirée. Reconnecte-toi pour continuer à sauvegarder la progression.', 'warning'); return; } lastSaveStatus = `Sauvegardé à ${formatClock(snap.time)}.`; setProgressUi({ state: 'saved', text: '', showText: false, ariaText: lastSaveStatus }); if (savedUiTimer) window.clearTimeout(savedUiTimer); savedUiTimer = window.setTimeout(() => { // Settle back to a quiet state (no spam), keep detail in aria-label. setProgressUi({ state: 'info', text: '—', showText: false, ariaText: lastSaveStatus }); }, 900); } 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(); // Ensure we create/refresh a progress row as soon as playback starts // (so the movies page can show a "resume" state quickly). if (!startedOnce) { startedOnce = true; if (startSaveTimer) window.clearTimeout(startSaveTimer); startSaveTimer = window.setTimeout(() => { void persist({ force: true }); }, 650); } }; 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 = () => { if (restoreInProgress) { restoreInProgress = false; pendingRestoreTime = null; } 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 }); }; // Restore may require multiple tries (duration may be unknown at first). videoEl.addEventListener('loadedmetadata', scheduleRestore); videoEl.addEventListener('durationchange', scheduleRestore); videoEl.addEventListener('loadeddata', scheduleRestore); videoEl.addEventListener('canplay', scheduleRestore); 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) scheduleRestore(); return () => { stopInterval(); if (seekSaveTimer) window.clearTimeout(seekSaveTimer); if (savedUiTimer) window.clearTimeout(savedUiTimer); if (startSaveTimer) window.clearTimeout(startSaveTimer); 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); videoEl.removeEventListener('loadedmetadata', scheduleRestore); videoEl.removeEventListener('durationchange', scheduleRestore); videoEl.removeEventListener('loadeddata', scheduleRestore); videoEl.removeEventListener('canplay', scheduleRestore); 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) { // Back-compat: older calls pass a string. We render minimal UI. setProgressUi({ state: 'info', text: text || '—', showText: true }); } function setProgressUi({ state, text, showText, ariaText } = {}) { const iconEl = document.getElementById('progress-status-icon'); const textEl = document.getElementById('progress-status-text'); const wrapEl = document.getElementById('progress-status'); if (!iconEl || !textEl) return; const s = String(state || '').trim().toLowerCase(); const t = typeof text === 'string' ? text : (text === null || text === undefined ? '' : String(text)); const shouldShowText = showText === undefined ? true : !!showText; const a11y = typeof ariaText === 'string' ? ariaText : t; // Reset icon iconEl.classList.remove('progress-icon--spinner'); iconEl.innerHTML = ''; // Text textEl.textContent = t || '—'; textEl.classList.toggle('is-hidden', !shouldShowText); // A11y label lives on the wrapper span. if (wrapEl) { if (a11y) wrapEl.setAttribute('aria-label', a11y); else wrapEl.removeAttribute('aria-label'); } // Icon by state (no external deps) if (s === 'saving' || s === 'preparing') { iconEl.classList.add('progress-icon--spinner'); return; } if (s === 'saved') { iconEl.innerHTML = ` `; return; } if (s === 'warning' || s === 'error') { iconEl.innerHTML = ` `; return; } } 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, { activeYear } = {}) { const titleEl = document.getElementById('movie-title'); const categoryEl = document.getElementById('movie-category'); const ratingEl = document.getElementById('movie-rating'); const year = Number.isInteger(activeYear) ? activeYear : null; const titlePrefix = year ? `Pool Oscars (${year})` : 'Pool Oscars'; if (titleEl) titleEl.textContent = movie?.title || titlePrefix; 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 ? `${titlePrefix} - ${movie.title}` : titlePrefix; } function normalizeRatingValue(v) { const n = Number(v); if (!Number.isFinite(n)) return null; const rounded = Math.round(n); if (rounded < 1 || rounded > 5) return null; return rounded; } function applyRatingState({ wrap, buttons, metaEl, averageRating, ratingsCount, userRating }) { if (!wrap || !buttons?.length || !metaEl) return; const avg = Number.isFinite(Number(averageRating)) ? Number(averageRating) : null; const count = Number.isFinite(Number(ratingsCount)) ? Number(ratingsCount) : 0; const user = Number.isFinite(Number(userRating)) ? Number(userRating) : null; buttons.forEach((btn) => { const value = Number(btn.getAttribute('data-value')); const isActive = user && value <= user; btn.classList.toggle('is-active', !!isActive); btn.setAttribute('aria-pressed', isActive ? 'true' : 'false'); }); const parts = []; if (avg && count > 0) { parts.push(`Note utilisateurs: ${avg.toFixed(1)} (${count})`); } else { parts.push('Note utilisateurs: —'); } metaEl.textContent = parts.join(' • '); } async function saveUserRating(movieId, rating, token) { try { const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/ratings`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}), }, body: JSON.stringify({ rating }), }); if (!res.ok) return null; return await res.json().catch(() => null); } catch (_) { return null; } } async function deleteUserRating(movieId, token) { try { const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/ratings`, { method: 'DELETE', headers: token ? { 'Authorization': `Bearer ${token}` } : undefined, }); if (!res.ok) return null; return await res.json().catch(() => null); } catch (_) { return null; } } function initUserRating({ movieId, token, initial } = {}) { const wrap = document.getElementById('user-rating'); const metaEl = document.getElementById('user-rating-meta'); if (!wrap || !metaEl) return; const buttons = Array.from(wrap.querySelectorAll('.star-btn')); const starting = { averageRating: initial?.averageRating ?? null, ratingsCount: initial?.ratingsCount ?? 0, userRating: initial?.userRating ?? null, }; applyRatingState({ wrap, buttons, metaEl, ...starting }); if (!token) { metaEl.textContent = 'Connecte-toi pour noter.'; buttons.forEach((btn) => btn.setAttribute('disabled', 'disabled')); return; } let currentRating = Number.isFinite(Number(starting.userRating)) ? Number(starting.userRating) : null; let isSaving = false; buttons.forEach((btn) => { btn.addEventListener('click', async () => { if (isSaving) return; const value = normalizeRatingValue(btn.getAttribute('data-value')); if (!value) return; isSaving = true; buttons.forEach((b) => b.setAttribute('disabled', 'disabled')); metaEl.textContent = 'Enregistrement…'; const shouldClear = currentRating && value === currentRating; const result = shouldClear ? await deleteUserRating(movieId, token) : await saveUserRating(movieId, value, token); if (result) { currentRating = typeof result?.userRating === 'number' ? result.userRating : null; applyRatingState({ wrap, buttons, metaEl, averageRating: result?.averageRating, ratingsCount: result?.ratingsCount, userRating: result?.userRating, }); } else { applyRatingState({ wrap, buttons, metaEl, ...starting }); metaEl.textContent = 'Impossible d’enregistrer la note.'; } buttons.forEach((b) => b.removeAttribute('disabled')); isSaving = false; }); }); } function normalizeVodLink(raw) { const v = String(raw || '').trim(); if (!v) return null; // Support pasted iframe embed code (extract src) if (v.toLowerCase().includes('