From 7ca80c160116ce5b2dcb88a4a85f7bef01d9938d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 23 Dec 2025 00:39:43 +0000 Subject: [PATCH] feat: Add movie playback progress display and API Co-authored-by: mathieumoran1 --- public/js/app_movies.js | 76 ++++++++++++++++++++++++++++++++++++++++- public/js/app_player.js | 14 +++++++- routes/movies.js | 41 ++++++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/public/js/app_movies.js b/public/js/app_movies.js index b3aea54..5eced52 100644 --- a/public/js/app_movies.js +++ b/public/js/app_movies.js @@ -151,6 +151,41 @@ window.addEventListener('DOMContentLoaded', async function () { subtitle: 'Récupération des données…' }); + 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')}`; + } + + async function fetchPlaybackProgressByYear(year, token) { + try { + const res = await fetch(`/api/movies/progress?year=${encodeURIComponent(String(year))}`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` }, + cache: 'no-store', + }); + if (!res.ok) return new Map(); + const rows = await res.json().catch(() => []); + const map = new Map(); + (Array.isArray(rows) ? rows : []).forEach((r) => { + const movieId = typeof r?.movieId === 'string' ? r.movieId : ''; + if (!movieId) return; + const time = Number(r?.time); + const duration = r?.duration === null || r?.duration === undefined ? null : Number(r.duration); + map.set(movieId, { + time: Number.isFinite(time) && time >= 0 ? time : 0, + duration: duration !== null && Number.isFinite(duration) && duration > 0 ? duration : null, + }); + }); + return map; + } catch (_) { + return new Map(); + } + } + function setLoadError(el, message) { if (!el) return; el.innerHTML = ` @@ -297,6 +332,7 @@ window.addEventListener('DOMContentLoaded', async function () { pageLoader.setProgress(18); let watchedMoviesInYear = []; + let progressByMovieId = new Map(); try { // Start summary fetch immediately so header can update ASAP. @@ -316,6 +352,11 @@ window.addEventListener('DOMContentLoaded', async function () { return null; }); + // Fetch progress in parallel (movies page uses it to show resume/progression). + const progressPromise = fetchPlaybackProgressByYear(activeYear, token) + .then((m) => { progressByMovieId = m instanceof Map ? m : new Map(); }) + .catch(() => { progressByMovieId = new Map(); }); + pageLoader.setProgress(28); const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, { method: 'GET', @@ -332,7 +373,7 @@ window.addEventListener('DOMContentLoaded', async function () { const movies = await res.json(); pageLoader.setProgress(55); // If summary is still in flight, wait for it before rendering watched banners. - await summaryPromise; + await Promise.all([summaryPromise, progressPromise]); pageLoader.setProgress(62); // Sort movies alphabetically by title (server also sorts, but keep client as a fallback) @@ -361,6 +402,7 @@ window.addEventListener('DOMContentLoaded', async function () { const hasNewPlayerSource = hasVideoSrc || hasEmbedSrc; const legacyUrl = cleanUrl(movie?.vod_link); const hasLegacy = !!legacyUrl; + const movieId = movie && movie._id ? String(movie._id) : ''; // Navigation: // - Prefer in-app player when we have a non-legacy source @@ -371,6 +413,37 @@ window.addEventListener('DOMContentLoaded', async function () { : (hasLegacy ? legacyUrl : ''); const isClickable = !!playerUrl; + // Progress: only show for in-app player sources (not legacy vod_link, not "no source"). + // Also avoid showing a meaningless "0:00" if they never really started. + let progressHtml = ''; + if (hasNewPlayerSource && movieId) { + const p = progressByMovieId.get(movieId); + const time = Number(p?.time) || 0; + const duration = p?.duration === null || p?.duration === undefined ? null : Number(p.duration); + if (time > 1) { + if (duration && Number.isFinite(duration) && duration > 0) { + const pct = Math.max(0, Math.min(100, (time / duration) * 100)); + progressHtml = ` +
+
+ Reprendre à ${formatClock(time)} + ${pct.toFixed(0)}% +
+
+
+
+
+ `; + } else { + progressHtml = ` +
+ Reprendre à ${formatClock(time)} +
+ `; + } + } + } + const movieDiv = document.createElement('div'); movieDiv.classList.add('col-12', 'col-sm-6', 'col-lg-4', 'movie-card'); movieDiv.setAttribute('data-imdb-id', movie.imdb_id); @@ -408,6 +481,7 @@ window.addEventListener('DOMContentLoaded', async function () {
${movie.category}

${movie.description}

${isChecked ? `
Regardé le: ${watchedDate}
` : ''} + ${progressHtml} `; moviesList.appendChild(movieDiv); diff --git a/public/js/app_player.js b/public/js/app_player.js index 99d9381..3c359b8 100644 --- a/public/js/app_player.js +++ b/public/js/app_player.js @@ -220,6 +220,8 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) { let lastSaveStatus = '—'; let seekSaveTimer = null; let savedUiTimer = null; + let startedOnce = false; + let startSaveTimer = null; function getSnapshot() { const t = Number(videoEl.currentTime); @@ -348,7 +350,16 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) { intervalId = null; } - const onPlay = () => startInterval(); + 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. @@ -396,6 +407,7 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) { 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); diff --git a/routes/movies.js b/routes/movies.js index 6a80f7d..0c85045 100644 --- a/routes/movies.js +++ b/routes/movies.js @@ -719,6 +719,47 @@ router.put("/:id/progress", authenticate, async (req, res) => { } }); +// Get playback progress for the current user (bulk, used by movies.html) +// Optional query: ?year=2026 (filters to that Oscar year) +router.get("/progress", authenticate, async (req, res) => { + try { + const userId = req.user?.id; + if (!userId || !mongoose.Types.ObjectId.isValid(userId)) { + return res.status(401).json({ message: "Invalid user" }); + } + + const year = parseOscarYear(req.query.year); + let movieIds = null; + + if (year) { + const idDocs = await Movie.find({ year }).select("_id").lean(); + movieIds = idDocs.map((d) => d?._id).filter(Boolean); + if (movieIds.length === 0) { + res.set("Cache-Control", "private, no-store, must-revalidate"); + res.set("Vary", "Authorization"); + return res.status(200).json([]); + } + } + + const filter = { userId, ...(movieIds ? { movieId: { $in: movieIds } } : {}) }; + const rows = await PlaybackProgress.find(filter).select("movieId time duration updatedAt").lean(); + + // User-specific: never allow shared/proxy caching. + res.set("Cache-Control", "private, no-store, must-revalidate"); + res.set("Vary", "Authorization"); + return res.status(200).json( + (rows || []).map((r) => ({ + movieId: String(r.movieId), + time: typeof r.time === "number" ? r.time : 0, + duration: typeof r.duration === "number" ? r.duration : null, + updatedAt: r.updatedAt ? new Date(r.updatedAt).toISOString() : null, + })) + ); + } catch (err) { + return res.status(500).json({ error: err.message }); + } +}); + // Get one movie by Mongo _id (used by the custom player page) router.get("/:id", async (req, res) => { try {