diff --git a/public/js/app_player.js b/public/js/app_player.js
index 91659a0..4ffbadd 100644
--- a/public/js/app_player.js
+++ b/public/js/app_player.js
@@ -1,5 +1,10 @@
function decodeJwt(token) {
- return JSON.parse(atob(token.split('.')[1]));
+ 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) {
@@ -10,6 +15,14 @@ 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 fetchPlaybackProgress(movieId, token) {
try {
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/progress`, {
@@ -60,11 +73,13 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
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);
@@ -76,12 +91,16 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
};
}
- async function restoreProgressOnce() {
- if (restored) return;
- restored = true;
+ 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.
- const server = await fetchPlaybackProgress(movieId, token);
+ if (!restoreServerPromise) restoreServerPromise = fetchPlaybackProgress(movieId, token);
+ const server = await restoreServerPromise;
let targetTime = server?.time ?? 0;
if (!Number.isFinite(targetTime) || targetTime < 0) targetTime = 0;
@@ -91,19 +110,24 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
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));
+ restored = true;
+
if (clamped > 1) {
try { videoEl.currentTime = clamped; } catch (_) {}
- lastSaveStatus = `Reprise à ${formatClock(clamped)}.`;
- setProgressStatus(lastSaveStatus);
+ lastSaveStatus = `Reprise · ${formatClock(clamped)}`;
+ setProgressUi({ state: 'info', text: lastSaveStatus, showText: true, ariaText: `Reprise à ${formatClock(clamped)}` });
} else {
- lastSaveStatus = 'Lecture depuis le début.';
- setProgressStatus(lastSaveStatus);
+ 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 } = {}) {
@@ -124,19 +148,25 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
localStorage.setItem(key, JSON.stringify({ time: snap.time, duration: snap.duration, at: now }));
} catch (_) {}
- setProgressStatus('Sauvegarde…');
+ // 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.';
- setProgressStatus(lastSaveStatus);
+ 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)}.`;
- setProgressStatus(lastSaveStatus);
+ 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() {
@@ -173,7 +203,11 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
if (document.visibilityState === 'hidden') void persist({ keepalive: true, force: true });
};
- videoEl.addEventListener('loadedmetadata', restoreProgressOnce, { once: 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);
@@ -186,11 +220,12 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
document.addEventListener('visibilitychange', onVisibility);
// If metadata is already available, try immediately.
- if (videoEl.readyState >= 1) void restoreProgressOnce();
+ 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);
@@ -198,6 +233,10 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
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);
@@ -239,9 +278,58 @@ function setSourceLabel(text) {
}
function setProgressStatus(text) {
- const el = document.getElementById('progress-status');
- if (!el) return;
- el.textContent = 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) {
@@ -423,6 +511,10 @@ function toVideoSessionUrl(videoSrc) {
}
}
+// Best-effort cache to avoid spamming /api/video/session.
+// Keyed by sessionUrl + token (token is already in-memory, not persisted here).
+const _videoSessionCache = new Map(); // key -> expiresAt ms
+
async function ensureVideoSessionForSource(videoSrc, token) {
// Only needed for our protected /api/video/* streams.
if (!token) return;
@@ -440,18 +532,64 @@ async function ensureVideoSessionForSource(videoSrc, token) {
}
})();
+ const cacheKey = `${sessionUrl}::${token}`;
+ const cachedUntil = _videoSessionCache.get(cacheKey) || 0;
+ if (Date.now() < cachedUntil) return;
+
try {
await fetch(sessionUrl, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
- ...(isCrossOrigin ? { credentials: 'include' } : {}),
+ // Always include credentials so the cookie is stored even if the browser
+ // treats this as cross-origin unexpectedly (hostname differences, etc).
+ credentials: 'include',
+ cache: 'no-store',
});
+ _videoSessionCache.set(cacheKey, Date.now() + 60_000); // 60s
} catch (_) {
// best-effort
}
}
-async function playVodLink(vodLink) {
+function addTokenToApiVideoUrl(rawUrl, token) {
+ if (!rawUrl || !token) return rawUrl;
+ try {
+ const isRelative = String(rawUrl).trim().startsWith('/');
+ const u = new URL(String(rawUrl), window.location.origin);
+ u.searchParams.set('token', token);
+ return isRelative ? `${u.pathname}${u.search}${u.hash}` : u.toString();
+ } catch (_) {
+ // Fallback: naive append
+ const s = String(rawUrl);
+ const join = s.includes('?') ? '&' : '?';
+ return `${s}${join}token=${encodeURIComponent(token)}`;
+ }
+}
+
+async function probeApiVideoReadable(rawUrl) {
+ try {
+ const res = await fetch(rawUrl, {
+ method: 'GET',
+ headers: { Range: 'bytes=0-0' },
+ credentials: 'include',
+ cache: 'no-store',
+ });
+ return res.status !== 401;
+ } catch (_) {
+ return true; // don't block playback if probe fails (CORS/network/etc)
+ }
+}
+
+async function ensurePlayableApiVideoUrl(rawUrl, token) {
+ if (!isApiVideoUrl(rawUrl) || !token) return rawUrl;
+ // IMPORTANT: Prefer query-token auth for media elements.
+ // Cookie-based auth is unreliable cross-origin (and even across ports in some setups)
+ // and causes intermittent 401s + preflight noise. Both Node + Python servers already
+ // support ?token=... so we use it consistently.
+ return addTokenToApiVideoUrl(rawUrl, token);
+}
+
+async function playVodLink(vodLink, { token } = {}) {
const raw = normalizeVodLink(vodLink);
if (!raw) {
showAlert('No VOD link configured for this movie.');
@@ -482,11 +620,12 @@ async function playVodLink(vodLink) {
setSourceLabel('Server video stream (/api/video)');
const videoEl = showVideoPlayer();
if (!videoEl) return;
- videoEl.src = raw;
+ const playable = await ensurePlayableApiVideoUrl(raw, token);
+ videoEl.src = playable;
videoEl.addEventListener('error', () => {
// If the browser can't play it (or CORS blocks), fall back to iframe.
setSourceLabel('Embed (fallback)');
- showEmbedPlayer(raw);
+ showEmbedPlayer(playable);
}, { once: true });
try { videoEl.load(); } catch (_) {}
return;
@@ -643,9 +782,7 @@ window.onload = async function () {
return;
}
- // Allow