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 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 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 restoreServerPromise = null;
let intervalId = null;
let lastSavedAt = 0;
let lastSavedTime = -1;
let lastSaveStatus = '—';
let seekSaveTimer = null;
let savedUiTimer = 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) {
try { videoEl.currentTime = clamped; } catch (_) {}
lastSaveStatus = `Reprise · ${formatClock(clamped)}`;
setProgressUi({ state: 'info', text: lastSaveStatus, showText: true, ariaText: `Reprise à ${formatClock(clamped)}` });
} else {
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 } = {}) {
// 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 (_) {}
// Icon-only saving indicator (avoid noisy text updates).
setProgressUi({ state: 'saving', text: '', showText: false, ariaText: '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.';
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();
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 });
};
// 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);
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 ? `Oscar Pool (${year})` : 'Oscar Pool';
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 normalizeVodLink(raw) {
const v = String(raw || '').trim();
if (!v) return null;
// Support pasted iframe embed code (extract src)
if (v.toLowerCase().includes('