Movie watch progression tracking #63
+79
-1
@@ -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 = `
|
||||
<div class="mt-2">
|
||||
<div class="d-flex justify-content-between align-items-center text-muted small">
|
||||
<span>Reprendre à <span class="fw-semibold text-dark">${formatClock(time)}</span></span>
|
||||
<span>${pct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 6px;">
|
||||
<div class="progress-bar" role="progressbar" style="width: ${pct.toFixed(2)}%;" aria-valuenow="${pct.toFixed(0)}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
progressHtml = `
|
||||
<div class="mt-2 text-muted small">
|
||||
Reprendre à <span class="fw-semibold text-dark">${formatClock(time)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 () {
|
||||
<div class="text-muted small fw-semibold fst-italic mb-2">${movie.category}</div>
|
||||
<p class="card-text small flex-grow-1 mb-2">${movie.description}</p>
|
||||
${isChecked ? `<div class="text-muted small"><span class="fw-semibold text-dark">Regardé le:</span> ${watchedDate}</div>` : ''}
|
||||
${progressHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
moviesList.appendChild(movieDiv);
|
||||
|
||||
+13
-1
@@ -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);
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-5">
|
||||
<div class="container mt-5 pb-5">
|
||||
<h2>Films</h2>
|
||||
|
||||
<!-- Summary card (match Statistiques/Checklist structure) -->
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user