add video backend server, fix styling and more #54

Merged
quoije merged 131 commits from 2026-video.backend into prod 2025-12-22 05:42:04 -05:00
7 changed files with 289 additions and 130 deletions
Showing only changes of commit 421206d280 - Show all commits
+34 -22
View File
@@ -44,11 +44,6 @@
.navbar-toggler-icon { .navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E");
} }
/* New styles for table */
.table-hover tbody tr:hover {
background-color: #f1f1f1;
cursor: pointer;
}
</style> </style>
</head> </head>
<body class="bg-light page-loading"> <body class="bg-light page-loading">
@@ -90,25 +85,42 @@
<div class="container mt-5"> <div class="container mt-5">
<h2>Checklist</h2> <h2>Checklist</h2>
<div id="watched-ratio" class="ratio"></div>
<h3 class="fs-6" style="margin-bottom: -1px;">Temps avant les Oscars (<span id="oscar-date-day-month">—</span> <span id="oscar-year">—</span>) - <span id="time-left"></span></h3> <!-- Summary card (match Stats page spacing/structure) -->
<h3 class="fs-6"><span id="movies-per-day"></span></h3> <div class="card shadow-sm mt-3">
<div class="progress my-3"> <div class="card-body">
<div id="progress-bar" class="progress-bar" role="progressbar" style="width: 0%; background-color: red;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div> <div class="d-flex flex-column gap-1">
<div id="watched-ratio" class="fw-semibold">—</div>
<div class="text-muted small">
Temps avant les Oscars (<span id="oscar-date-day-month">—</span> <span id="oscar-year">—</span>) — <span id="time-left">—</span>
</div>
<div class="text-muted small"><span id="movies-per-day"></span></div>
</div>
<div class="progress mt-3" style="height: 10px;">
<div id="progress-bar" class="progress-bar" role="progressbar" style="width: 0%; background-color: red;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-busy="true"></div>
</div>
</div>
</div> </div>
<!-- Table for movies --> <!-- Table for movies -->
<table class="table table-hover movie-table"> <div class="table-responsive mt-3">
<thead> <table class="table table-striped table-hover align-middle movie-table mb-0">
<tr> <thead>
<th scope="col">Vu</th> <tr>
<th scope="col">Titre</th> <th scope="col">Vu</th>
<th scope="col">Date</th> <th scope="col">Titre</th>
</tr> <th scope="col">Date</th>
</thead> </tr>
<tbody id="movie-table-body"> </thead>
<!-- Movie rows will be inserted here --> <tbody id="movie-table-body">
</tbody> <tr>
</table> <td colspan="3" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</td>
</tr>
</tbody>
</table>
</div>
<br><br> <br><br>
</div> </div>
+19
View File
@@ -164,4 +164,23 @@ footer {
.app-version-list .list-group-item:last-child { .app-version-list .list-group-item:last-child {
border-bottom: 0; border-bottom: 0;
}
/* Movies page helpers (Bootstrap-first, minimal custom) */
.movie-poster {
width: 100%;
max-height: 360px;
object-fit: contain;
}
.imdb-icon {
width: 26px;
height: 26px;
vertical-align: middle;
margin-left: 6px;
display: inline-block;
}
.watched-badge {
z-index: 2;
} }
+11 -8
View File
@@ -453,12 +453,9 @@ window.addEventListener('DOMContentLoaded', async function () {
<td id="watched-date-${movie.imdb_id}">${watchedDate}</td> <td id="watched-date-${movie.imdb_id}">${watchedDate}</td>
`; `;
// Set initial row color based on watched state // Use Bootstrap table state classes (matches Stats page styling better than custom backgrounds)
if (watchedMovie) { movieRow.classList.toggle('table-success', Boolean(watchedMovie));
movieRow.style.backgroundColor = 'rgba(144, 238, 144, 0.6)'; // Lighter light green with transparency movieRow.classList.toggle('table-danger', !watchedMovie);
} else {
movieRow.style.backgroundColor = 'rgba(255, 99, 71, 0.6)'; // Lighter light red with transparency
}
movieTableBody.appendChild(movieRow); movieTableBody.appendChild(movieRow);
@@ -478,10 +475,16 @@ window.addEventListener('DOMContentLoaded', async function () {
if (isChecked) { if (isChecked) {
watchedDateCell.textContent = new Date().toLocaleString(); watchedDateCell.textContent = new Date().toLocaleString();
row.style.backgroundColor = 'rgba(144, 238, 144, 0.6)'; // Apply light green with transparency if (row) {
row.classList.add('table-success');
row.classList.remove('table-danger');
}
} else { } else {
watchedDateCell.textContent = ''; watchedDateCell.textContent = '';
row.style.backgroundColor = 'rgba(255, 99, 71, 0.6)'; // Apply light red with transparency if (row) {
row.classList.add('table-danger');
row.classList.remove('table-success');
}
} }
await fetch('/api/movies/users/updateWatchedMovies', { await fetch('/api/movies/users/updateWatchedMovies', {
+22 -13
View File
@@ -359,22 +359,31 @@ window.addEventListener('DOMContentLoaded', async function () {
: (hasLegacy ? movie.vod_link : (movie && movie._id ? `/player.html?id=${encodeURIComponent(movie._id)}` : '#')); : (hasLegacy ? movie.vod_link : (movie && movie._id ? `/player.html?id=${encodeURIComponent(movie._id)}` : '#'));
const movieDiv = document.createElement('div'); const movieDiv = document.createElement('div');
movieDiv.classList.add('col-md-4', 'mb-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);
movieDiv.innerHTML = ` movieDiv.innerHTML = `
<div class="card"> <div class="card h-100 shadow-sm">
${isChecked ? '<div class="watched-banner">VISIONNÉ</div>' : ''} ${isChecked ? '<span class="badge bg-success position-absolute top-0 start-0 m-2 watched-badge">VISIONNÉ</span>' : ''}
<a href="${playerUrl}" target="_self" rel="noopener noreferrer"> <a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none">
<img src="${movie.poster}" class="card-img-top" alt="${movie.title}" style="width: 75%; display: block; margin: 0 auto; padding: 10px;"> <div class="text-center pt-3 px-3">
<img src="${movie.poster}" class="img-fluid rounded movie-poster" alt="${movie.title}">
</div>
</a> </a>
<div class="card-body"> <div class="card-body d-flex flex-column">
<h5 class="card-title d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-start gap-2">
<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none text-dark">${movie.title}</a> <h5 class="card-title mb-1 flex-grow-1">
<span>⭐ ${movie.rating} <a href="https://www.imdb.com/title/${movie.imdb_id}/"><img src="/img/imdb.png"></a></span> <a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none text-dark">${movie.title}</a>
</h5> </h5>
<p class="fw-bold fst-italic card-text" style="font-size: 0.75rem;">${movie.category}</p> <div class="text-nowrap small text-muted">
<p class="card-text">${movie.description}</p> ⭐ ${movie.rating}
${isChecked ? `<p class="card-text"><strong>Regardé le:</strong> ${watchedDate}</p>` : ''} <a href="https://www.imdb.com/title/${movie.imdb_id}/" target="_blank" rel="noopener noreferrer" aria-label="Voir sur IMDb">
<img src="/img/imdb.png" class="imdb-icon" alt="IMDb">
</a>
</div>
</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>
${isChecked ? `<div class="text-muted small"><span class="fw-semibold text-dark">Regardé le:</span> ${watchedDate}</div>` : ''}
</div> </div>
</div>`; </div>`;
moviesList.appendChild(movieDiv); moviesList.appendChild(movieDiv);
+121
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();
@@ -782,6 +889,8 @@ window.onload = async function () {
if (decoded.admin) { if (decoded.admin) {
const adminLink = document.getElementById('admin-control-link'); const adminLink = document.getElementById('admin-control-link');
if (adminLink) adminLink.classList.remove('d-none'); if (adminLink) adminLink.classList.remove('d-none');
const adminStatus = document.getElementById('player-admin-status');
if (adminStatus) adminStatus.classList.remove('d-none');
} }
const logoffBtn = document.getElementById('log-off'); const logoffBtn = document.getElementById('log-off');
@@ -796,14 +905,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 +923,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 +947,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 +969,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();
} }
}; };
+27 -56
View File
@@ -21,42 +21,9 @@
color: #ddd; color: #ddd;
} }
/* Style for the 'WATCHED' banner */
.watched-banner {
background-color: green;
color: white;
text-align: center;
padding: 5px;
font-weight: bold;
position: absolute;
top: 10px;
width: 100%;
z-index: 1;
}
/* Centering checkbox and making card look better */
.movie-card {
position: relative;
margin-bottom: 20px;
}
.card-body {
position: relative;
}
#movies-list { #movies-list {
margin-top: 10px; margin-top: 10px;
} }
/* Style for the filter button */
#filter-button {
margin-left: 15px;
}
/* Style for the ratio display */
.ratio {
font-weight: bold;
margin-top: 10px;
}
.navbar-toggler { .navbar-toggler {
border-color: white; /* Make the border white */ border-color: white; /* Make the border white */
} }
@@ -104,31 +71,35 @@
</nav> </nav>
<div class="container mt-5"> <div class="container mt-5">
<div class="d-flex justify-content-between align-items-center"> <h2>Films</h2>
<h2>Films</h2>
<!-- Summary card (match Statistiques/Checklist structure) -->
<div class="card shadow-sm mt-3">
<div class="card-body">
<div class="d-flex flex-column gap-1">
<div class="text-muted small">
Dernière mise à jour des films: <span class="fw-semibold text-dark" id="movies-last-updated">—</span>
</div>
<div id="watched-ratio" class="fw-semibold">—</div>
<div class="text-muted small">
Temps avant les Oscars (<span id="oscar-date-day-month">—</span> <span id="oscar-year">—</span>) — <span id="time-left">—</span>
</div>
</div>
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between gap-2 mt-3">
<div class="form-check form-switch m-0">
<input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault">
<label class="form-check-label" for="flexSwitchCheckDefault">Films non regardés seulement</label>
</div>
<div class="text-muted small">Clique sur l’affiche ou le titre pour ouvrir le lecteur.</div>
</div>
</div>
</div> </div>
<!-- Displaying last updated vod_links --> <div id="movies-list" class="row g-3 mt-1">
<div class="alert alert-warning fade show" role="alert"> <div class="col-12">
Dernière mise à jour des films: <strong id="movies-last-updated">—</strong> <div class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</div>
</div> </div>
<!-- Displaying the ratio of watched movies -->
<div id="watched-ratio" class="ratio">
<!-- This will display the ratio -->
</div>
<h3 class="fs-6">Temps avant les Oscars (<span id="oscar-date-day-month">—</span> <span id="oscar-year">—</span>) - <span id="time-left"></span></h3>
<h3 class="fs-6 mb-0">
</h3>
<br>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault">
<label class="form-check-label" for="flexSwitchCheckDefault">Films non regardés seulement</label>
</div>
<div id="movies-list" class="row">
<!-- Movies will be displayed here -->
</div> </div>
</div> </div>
+55 -31
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,39 +138,53 @@
</nav> </nav>
<div class="container mt-5 pb-4 player-wrap"> <div class="container mt-5 pb-4 player-wrap">
<div class="mb-3"> <div id="alert" class="alert alert-danger d-none" role="alert"></div>
<h2 id="movie-title" class="mb-1">Loading…</h2>
<div class="meta-muted"> <!-- Header / metadata -->
<span id="movie-category"></span> <div class="card shadow-sm mt-3">
<span id="movie-rating"></span> <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>
<!-- Admin-only debug/status (Source + Progress) -->
<div id="player-admin-status" class="d-none 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>
</div> </div>
<div id="alert" class="alert alert-danger d-none" role="alert"></div> <!-- Player surface -->
<div class="card shadow-sm mt-3">
<div class="player-surface"> <div class="card-body p-0">
<video id="video" class="d-none" controls playsinline></video> <div class="player-surface">
<iframe <video id="video" class="d-none" controls playsinline></video>
id="embed" <iframe
class="d-none" id="embed"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" class="d-none"
allowfullscreen allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="no-referrer-when-downgrade" allowfullscreen
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-presentation" referrerpolicy="no-referrer-when-downgrade"
></iframe> sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-presentation"
</div> ></iframe>
</div>
<div class="mt-3 d-flex flex-wrap gap-2 align-items-center"> </div>
<span class="badge bg-secondary">Source</span>
<span id="source-label" class="badge bg-light text-dark badge-src">—</span>
</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>