diff --git a/public/js/app_movies.js b/public/js/app_movies.js
index b3aea54..ddd1c43 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,41 @@ 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));
+ // Hide "resume/progress" once we're basically at the credits.
+ // Backend uses 95% as the "credit roll" threshold too.
+ if (pct < 95) {
+ 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 +485,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/public/movies.html b/public/movies.html
index b0ce59d..d84fab4 100644
--- a/public/movies.html
+++ b/public/movies.html
@@ -70,7 +70,7 @@
-
+
Films
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 {