Movie watch progression tracking #63
+79
-1
@@ -151,6 +151,41 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
subtitle: 'Récupération des données…'
|
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) {
|
function setLoadError(el, message) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
@@ -297,6 +332,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
pageLoader.setProgress(18);
|
pageLoader.setProgress(18);
|
||||||
|
|
||||||
let watchedMoviesInYear = [];
|
let watchedMoviesInYear = [];
|
||||||
|
let progressByMovieId = new Map();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Start summary fetch immediately so header can update ASAP.
|
// Start summary fetch immediately so header can update ASAP.
|
||||||
@@ -316,6 +352,11 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
return null;
|
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);
|
pageLoader.setProgress(28);
|
||||||
const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, {
|
const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -332,7 +373,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
const movies = await res.json();
|
const movies = await res.json();
|
||||||
pageLoader.setProgress(55);
|
pageLoader.setProgress(55);
|
||||||
// If summary is still in flight, wait for it before rendering watched banners.
|
// If summary is still in flight, wait for it before rendering watched banners.
|
||||||
await summaryPromise;
|
await Promise.all([summaryPromise, progressPromise]);
|
||||||
pageLoader.setProgress(62);
|
pageLoader.setProgress(62);
|
||||||
|
|
||||||
// Sort movies alphabetically by title (server also sorts, but keep client as a fallback)
|
// 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 hasNewPlayerSource = hasVideoSrc || hasEmbedSrc;
|
||||||
const legacyUrl = cleanUrl(movie?.vod_link);
|
const legacyUrl = cleanUrl(movie?.vod_link);
|
||||||
const hasLegacy = !!legacyUrl;
|
const hasLegacy = !!legacyUrl;
|
||||||
|
const movieId = movie && movie._id ? String(movie._id) : '';
|
||||||
|
|
||||||
// Navigation:
|
// Navigation:
|
||||||
// - Prefer in-app player when we have a non-legacy source
|
// - Prefer in-app player when we have a non-legacy source
|
||||||
@@ -371,6 +413,41 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
: (hasLegacy ? legacyUrl : '');
|
: (hasLegacy ? legacyUrl : '');
|
||||||
const isClickable = !!playerUrl;
|
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');
|
const movieDiv = document.createElement('div');
|
||||||
movieDiv.classList.add('col-12', 'col-sm-6', 'col-lg-4', 'movie-card');
|
movieDiv.classList.add('col-12', 'col-sm-6', 'col-lg-4', 'movie-card');
|
||||||
movieDiv.setAttribute('data-imdb-id', movie.imdb_id);
|
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>
|
<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>
|
<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>` : ''}
|
${isChecked ? `<div class="text-muted small"><span class="fw-semibold text-dark">Regardé le:</span> ${watchedDate}</div>` : ''}
|
||||||
|
${progressHtml}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
moviesList.appendChild(movieDiv);
|
moviesList.appendChild(movieDiv);
|
||||||
|
|||||||
+13
-1
@@ -220,6 +220,8 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) {
|
|||||||
let lastSaveStatus = '—';
|
let lastSaveStatus = '—';
|
||||||
let seekSaveTimer = null;
|
let seekSaveTimer = null;
|
||||||
let savedUiTimer = null;
|
let savedUiTimer = null;
|
||||||
|
let startedOnce = false;
|
||||||
|
let startSaveTimer = null;
|
||||||
|
|
||||||
function getSnapshot() {
|
function getSnapshot() {
|
||||||
const t = Number(videoEl.currentTime);
|
const t = Number(videoEl.currentTime);
|
||||||
@@ -348,7 +350,16 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) {
|
|||||||
intervalId = null;
|
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 onPause = () => { stopInterval(); void persist({ force: true }); };
|
||||||
const onSeeking = () => {
|
const onSeeking = () => {
|
||||||
// While scrubbing, browsers fire many events; debounce a forced save so it lands quickly.
|
// 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();
|
stopInterval();
|
||||||
if (seekSaveTimer) window.clearTimeout(seekSaveTimer);
|
if (seekSaveTimer) window.clearTimeout(seekSaveTimer);
|
||||||
if (savedUiTimer) window.clearTimeout(savedUiTimer);
|
if (savedUiTimer) window.clearTimeout(savedUiTimer);
|
||||||
|
if (startSaveTimer) window.clearTimeout(startSaveTimer);
|
||||||
videoEl.removeEventListener('play', onPlay);
|
videoEl.removeEventListener('play', onPlay);
|
||||||
videoEl.removeEventListener('pause', onPause);
|
videoEl.removeEventListener('pause', onPause);
|
||||||
videoEl.removeEventListener('seeking', onSeeking);
|
videoEl.removeEventListener('seeking', onSeeking);
|
||||||
|
|||||||
+1
-1
@@ -70,7 +70,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="container mt-5">
|
<div class="container mt-5 pb-5">
|
||||||
<h2>Films</h2>
|
<h2>Films</h2>
|
||||||
|
|
||||||
<!-- Summary card (match Statistiques/Checklist structure) -->
|
<!-- 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)
|
// Get one movie by Mongo _id (used by the custom player page)
|
||||||
router.get("/:id", async (req, res) => {
|
router.get("/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user