feat: Add video playback progress display and saving

Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
Cursor Agent
2025-12-22 06:40:18 +00:00
co-authored by mathieumoran1
parent 8590c30596
commit ce234d0fac
2 changed files with 53 additions and 7 deletions
+48 -7
View File
@@ -63,6 +63,7 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
let intervalId = null; let intervalId = null;
let lastSavedAt = 0; let lastSavedAt = 0;
let lastSavedTime = -1; let lastSavedTime = -1;
let lastSaveStatus = '—';
function getSnapshot() { function getSnapshot() {
const t = Number(videoEl.currentTime); const t = Number(videoEl.currentTime);
@@ -96,13 +97,18 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
const clamped = Math.min(Math.max(targetTime, 0), Math.max(0, dur - 3)); const clamped = Math.min(Math.max(targetTime, 0), Math.max(0, dur - 3));
if (clamped > 1) { if (clamped > 1) {
try { videoEl.currentTime = clamped; } catch (_) {} try { videoEl.currentTime = clamped; } catch (_) {}
lastSaveStatus = `Reprise à ${formatClock(clamped)}.`;
setProgressStatus(lastSaveStatus);
} else {
lastSaveStatus = 'Lecture depuis le début.';
setProgressStatus(lastSaveStatus);
} }
} }
async function persist({ keepalive } = {}) { async function persist({ keepalive, force } = {}) {
// Throttle to avoid spamming the API // Throttle to avoid spamming the API
const now = Date.now(); const now = Date.now();
if (!keepalive && now - lastSavedAt < 2000) return; if (!force && !keepalive && now - lastSavedAt < 3000) return;
const snap = getSnapshot(); const snap = getSnapshot();
if (!Number.isFinite(snap.time)) return; if (!Number.isFinite(snap.time)) return;
@@ -117,18 +123,28 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
localStorage.setItem(key, JSON.stringify({ time: snap.time, duration: snap.duration, at: now })); localStorage.setItem(key, JSON.stringify({ time: snap.time, duration: snap.duration, at: now }));
} catch (_) {} } catch (_) {}
setProgressStatus('Sauvegarde…');
const ok = await savePlaybackProgress(movieId, token, { ...snap, keepalive: !!keepalive }); const ok = await savePlaybackProgress(movieId, token, { ...snap, keepalive: !!keepalive });
if (ok === false) { if (ok === false) {
// Token expired mid-playback; stop spamming requests and prompt user. // Token expired mid-playback; stop spamming requests and prompt user.
stopInterval(); stopInterval();
try { localStorage.removeItem('auth_token'); } catch (_) {} 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'); showAlertVariant('Session expirée. Reconnecte-toi pour continuer à sauvegarder la progression.', 'warning');
return;
} }
lastSaveStatus = `Sauvegardé à ${formatClock(snap.time)}.`;
setProgressStatus(lastSaveStatus);
} }
function startInterval() { function startInterval() {
if (intervalId) return; if (intervalId) return;
intervalId = window.setInterval(() => { void persist(); }, 10000); // Safety-net; primary saving happens via timeupdate throttling.
intervalId = window.setInterval(() => {
if (videoEl.paused || videoEl.seeking) return;
void persist();
}, 15000);
} }
function stopInterval() { function stopInterval() {
@@ -138,13 +154,17 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
} }
const onPlay = () => startInterval(); const onPlay = () => startInterval();
const onPause = () => { stopInterval(); void persist(); }; const onPause = () => { stopInterval(); void persist({ force: true }); };
const onSeeked = () => { void persist(); }; const onSeeked = () => { void persist({ force: true }); };
const onEnded = () => { stopInterval(); void persist(); }; const onEnded = () => { stopInterval(); void persist(); };
const onError = () => { stopInterval(); }; const onError = () => { stopInterval(); };
const onPageHide = () => { void persist({ keepalive: true }); }; const onTimeUpdate = () => {
if (videoEl.paused || videoEl.seeking) return;
void persist();
};
const onPageHide = () => { void persist({ keepalive: true, force: true }); };
const onVisibility = () => { const onVisibility = () => {
if (document.visibilityState === 'hidden') void persist({ keepalive: true }); if (document.visibilityState === 'hidden') void persist({ keepalive: true, force: true });
}; };
videoEl.addEventListener('loadedmetadata', restoreProgressOnce, { once: true }); videoEl.addEventListener('loadedmetadata', restoreProgressOnce, { once: true });
@@ -153,6 +173,7 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
videoEl.addEventListener('seeked', onSeeked); videoEl.addEventListener('seeked', onSeeked);
videoEl.addEventListener('ended', onEnded); videoEl.addEventListener('ended', onEnded);
videoEl.addEventListener('error', onError); videoEl.addEventListener('error', onError);
videoEl.addEventListener('timeupdate', onTimeUpdate);
window.addEventListener('pagehide', onPageHide); window.addEventListener('pagehide', onPageHide);
document.addEventListener('visibilitychange', onVisibility); document.addEventListener('visibilitychange', onVisibility);
@@ -167,8 +188,10 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
videoEl.removeEventListener('seeked', onSeeked); videoEl.removeEventListener('seeked', onSeeked);
videoEl.removeEventListener('ended', onEnded); videoEl.removeEventListener('ended', onEnded);
videoEl.removeEventListener('error', onError); videoEl.removeEventListener('error', onError);
videoEl.removeEventListener('timeupdate', onTimeUpdate);
window.removeEventListener('pagehide', onPageHide); window.removeEventListener('pagehide', onPageHide);
document.removeEventListener('visibilitychange', onVisibility); document.removeEventListener('visibilitychange', onVisibility);
if (lastSaveStatus) setProgressStatus(lastSaveStatus);
}; };
} }
@@ -206,6 +229,21 @@ function setSourceLabel(text) {
el.title = 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) { function setHeader(movie) {
const titleEl = document.getElementById('movie-title'); const titleEl = document.getElementById('movie-title');
const categoryEl = document.getElementById('movie-category'); const categoryEl = document.getElementById('movie-category');
@@ -614,7 +652,10 @@ window.onload = async function () {
// Only the <video> player supports reliable progress tracking (mp4/hls). // Only the <video> player supports reliable progress tracking (mp4/hls).
const videoEl = document.getElementById('video'); const videoEl = document.getElementById('video');
if (videoEl && !videoEl.classList.contains('d-none')) { if (videoEl && !videoEl.classList.contains('d-none')) {
setProgressStatus('Prépare la reprise…');
setupVideoProgress({ videoEl, movieId: id, token, userId: decoded?.id }); setupVideoProgress({ videoEl, movieId: id, token, userId: decoded?.id });
} else {
setProgressStatus('—');
} }
} catch (err) { } catch (err) {
showAlert(err.message || 'Network error.'); showAlert(err.message || 'Network error.');
+5
View File
@@ -115,6 +115,11 @@
<span class="badge bg-secondary">Source</span> <span class="badge bg-secondary">Source</span>
<span id="source-label" class="badge bg-light text-dark badge-src">—</span> <span id="source-label" class="badge bg-light text-dark badge-src">—</span>
</div> </div>
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
<span class="badge bg-secondary">Progress</span>
<span id="progress-status" class="text-muted small">—</span>
</div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>