From 8d93c31df8a6dba362613d3c769fafa2df7c5b3d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Dec 2025 06:57:58 +0000 Subject: [PATCH 1/3] Refactor video progress tracking UI and logic Improve progress UI, handle token expiration, and fix restore logic. Co-authored-by: mathieumoran1 --- public/js/app_player.js | 140 +++++++++++++++++++++++++++++++++------- public/player.html | 43 +++++++++++- 2 files changed, 159 insertions(+), 24 deletions(-) diff --git a/public/js/app_player.js b/public/js/app_player.js index 91659a0..bdaf8de 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) { @@ -661,10 +749,16 @@ window.onload = async function () { // Only the