feat: Add page loader component and integrate into player

Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
Cursor Agent
2025-12-22 10:27:30 +00:00
co-authored by mathieumoran1
parent b5dd58f80a
commit 34fcee3253
2 changed files with 173 additions and 31 deletions
+119
View File
@@ -7,6 +7,110 @@ function decodeJwt(token) {
return JSON.parse(atob(padded)); return JSON.parse(atob(padded));
} }
function createPageLoader(options = {}) {
const title = String(options.title || 'Chargement…');
let progress = 0;
let removed = false;
let navResizeObserver = null;
// Reuse a preloaded overlay if present (prevents initial "clear" flash).
const preloadedOverlay = document.getElementById('page-loader-overlay');
const overlay = preloadedOverlay || document.createElement('div');
if (!preloadedOverlay) {
overlay.className = 'page-loader-overlay';
overlay.setAttribute('role', 'status');
overlay.setAttribute('aria-live', 'polite');
overlay.setAttribute('aria-busy', 'true');
overlay.innerHTML = `
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">${title}</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar"></div>
</div>
</div>
`;
} else {
const label = overlay.querySelector('.visually-hidden');
if (label) label.textContent = title;
}
const barEl = () => overlay.querySelector('#page-loader-bar');
function clampPct(p) {
const n = Number(p);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, n));
}
function setProgress(p) {
progress = clampPct(p);
const bar = barEl();
if (bar) bar.style.width = `${progress}%`;
}
function getNavHeightPx() {
const nav = document.querySelector('nav.navbar');
if (!nav) return 0;
const rect = nav.getBoundingClientRect();
const h = Number(rect?.height) || 0;
return h > 0 ? Math.round(h) : 0;
}
function updateOverlayTopOffset() {
overlay.style.setProperty('--page-loader-top', `${getNavHeightPx()}px`);
}
function ensureMounted() {
if (removed) return;
updateOverlayTopOffset();
document.body.classList.add('page-loading');
if (!overlay.isConnected) document.body.appendChild(overlay);
if (!navResizeObserver) {
const nav = document.querySelector('nav.navbar');
if (nav && typeof ResizeObserver !== 'undefined') {
try {
navResizeObserver = new ResizeObserver(() => updateOverlayTopOffset());
navResizeObserver.observe(nav);
} catch (_) {
navResizeObserver = null;
}
}
}
}
function hideAndRemoveSoon() {
if (removed) return;
overlay.classList.add('page-loader-hide');
overlay.setAttribute('aria-busy', 'false');
document.body.classList.remove('page-loading');
window.setTimeout(() => {
removed = true;
if (navResizeObserver) {
try { navResizeObserver.disconnect(); } catch (_) {}
navResizeObserver = null;
}
try { overlay.remove(); } catch (_) {}
}, 220);
}
function done() {
setProgress(100);
hideAndRemoveSoon();
}
function fail() {
setProgress(Math.max(progress, 95));
window.setTimeout(() => hideAndRemoveSoon(), 200);
}
ensureMounted();
setProgress(8);
return { setProgress, done, fail };
}
function safeJsonParse(v) { function safeJsonParse(v) {
try { return JSON.parse(v); } catch (_) { return null; } try { return JSON.parse(v); } catch (_) { return null; }
} }
@@ -754,12 +858,15 @@ function resolveMovieSource(movie) {
} }
window.onload = async function () { window.onload = async function () {
const pageLoader = createPageLoader({ title: 'Chargement du lecteur' });
// Basic auth UX: require a valid local token (like the rest of the app). // Basic auth UX: require a valid local token (like the rest of the app).
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
if (!token) { if (!token) {
window.location.href = '/'; window.location.href = '/';
return; return;
} }
pageLoader.setProgress(14);
// Load the active Oscar year in parallel with the movie fetch. // Load the active Oscar year in parallel with the movie fetch.
const activeYearPromise = fetchActiveOscarYear(); const activeYearPromise = fetchActiveOscarYear();
@@ -796,14 +903,17 @@ window.onload = async function () {
window.location.href = '/'; window.location.href = '/';
return; return;
} }
pageLoader.setProgress(26);
const id = getQueryParam('id'); const id = getQueryParam('id');
if (!id) { if (!id) {
showAlert('Missing movie id.'); showAlert('Missing movie id.');
pageLoader.fail();
return; return;
} }
try { try {
pageLoader.setProgress(36);
const res = await fetch(`/api/movies/${encodeURIComponent(id)}`, { const res = await fetch(`/api/movies/${encodeURIComponent(id)}`, {
method: 'GET', method: 'GET',
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined, headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
@@ -811,17 +921,20 @@ window.onload = async function () {
const movie = await res.json().catch(() => ({})); const movie = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
showAlert(movie?.message || movie?.error || `Failed to load movie (${res.status}).`); showAlert(movie?.message || movie?.error || `Failed to load movie (${res.status}).`);
pageLoader.fail();
return; return;
} }
const activeYear = await activeYearPromise; const activeYear = await activeYearPromise;
setHeader(movie, { activeYear }); setHeader(movie, { activeYear });
pageLoader.setProgress(58);
const resolved = resolveMovieSource(movie); const resolved = resolveMovieSource(movie);
if (!resolved?.src) { if (!resolved?.src) {
setSourceLabel('—'); setSourceLabel('—');
hideAllPlayers(); hideAllPlayers();
showAlert('No playable source configured for this movie.'); showAlert('No playable source configured for this movie.');
pageLoader.fail();
return; return;
} }
@@ -832,10 +945,13 @@ window.onload = async function () {
setSourceLabel('Legacy link'); setSourceLabel('Legacy link');
hideAllPlayers(); hideAllPlayers();
showAlertVariant('Legacy source: this link is not playable in the in-app player.', 'warning'); showAlertVariant('Legacy source: this link is not playable in the in-app player.', 'warning');
pageLoader.done();
return; return;
} }
pageLoader.setProgress(70);
await playVodLink(resolved.src, { token }); await playVodLink(resolved.src, { token });
pageLoader.setProgress(82);
// 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');
@@ -851,8 +967,11 @@ window.onload = async function () {
} else { } else {
setProgressUi({ state: 'info', text: '—', showText: true }); setProgressUi({ state: 'info', text: '—', showText: true });
} }
pageLoader.setProgress(92);
pageLoader.done();
} catch (err) { } catch (err) {
showAlert(err.message || 'Network error.'); showAlert(err.message || 'Network error.');
pageLoader.fail();
} }
}; };
+43 -20
View File
@@ -102,7 +102,17 @@
} }
</style> </style>
</head> </head>
<body class="bg-light"> <body class="bg-light page-loading">
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">Chargement…</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
</div>
</div>
</div>
<!-- Black banner with buttons and user info --> <!-- Black banner with buttons and user info -->
<nav class="navbar navbar-expand-lg navbar-custom"> <nav class="navbar navbar-expand-lg navbar-custom">
<div class="container"> <div class="container">
@@ -128,16 +138,40 @@
</nav> </nav>
<div class="container mt-5 pb-4 player-wrap"> <div class="container mt-5 pb-4 player-wrap">
<div class="mb-3">
<h2 id="movie-title" class="mb-1">Loading…</h2>
<div class="meta-muted">
<span id="movie-category"></span>
<span id="movie-rating"></span>
</div>
</div>
<div id="alert" class="alert alert-danger d-none" role="alert"></div> <div id="alert" class="alert alert-danger d-none" role="alert"></div>
<!-- Header / metadata -->
<div class="card shadow-sm mt-3">
<div class="card-body">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
<div>
<h2 id="movie-title" class="mb-1">Chargement…</h2>
<div class="text-muted small">
<span id="movie-category"></span><span id="movie-rating"></span>
</div>
</div>
<div class="d-flex flex-column gap-2">
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="badge bg-secondary">Source</span>
<span id="source-label" class="badge bg-light text-dark badge-src">—</span>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="badge bg-secondary">Progress</span>
<span id="progress-status" class="text-muted small progress-indicator">
<span id="progress-status-icon" class="progress-icon" aria-hidden="true"></span>
<span id="progress-status-text" class="progress-text">—</span>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Player surface -->
<div class="card shadow-sm mt-3">
<div class="card-body p-0">
<div class="player-surface"> <div class="player-surface">
<video id="video" class="d-none" controls playsinline></video> <video id="video" class="d-none" controls playsinline></video>
<iframe <iframe
@@ -149,18 +183,7 @@
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-presentation" sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-presentation"
></iframe> ></iframe>
</div> </div>
<div class="mt-3 d-flex flex-wrap gap-2 align-items-center">
<span class="badge bg-secondary">Source</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 progress-indicator">
<span id="progress-status-icon" class="progress-icon" aria-hidden="true"></span>
<span id="progress-status-text" class="progress-text">—</span>
</span>
</div> </div>
</div> </div>