Add episode thumbnails and playlist player

This commit is contained in:
Dymas
2026-09-15 16:17:57 +02:00
parent 55954376e2
commit 2597aeaf1d
8 changed files with 261 additions and 55 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog # Changelog
## 1.5.0 - 2026-09-15
- Replaced inline episode video players with clickable episode cards using cached animated WebP thumbnails generated by ffmpeg.
- Added automatic missing episode thumbnail generation during library scans, including newly added episodes.
- Added fullscreen playback for episode cards and a `Play all` playlist flow that advances through a movie or series.
- Removed redundant helper copy from the main website interface.
## 1.4.2 - 2026-09-15 ## 1.4.2 - 2026-09-15
- Hid display title and thumbnail path from the read-only metadata view; those fields now appear only while editing metadata. - Hid display title and thumbnail path from the read-only metadata view; those fields now appear only while editing metadata.
+2
View File
@@ -4,6 +4,8 @@ h-player is a small local web app for browsing a folder-based private video coll
The sidebar holds app navigation, search, and rescanning controls. Movies and series appear in the main collection grid, similar to Jellyfin-style library browsing. Selecting a collection card opens that movie or series folder with its metadata, seasons, episodes, and playable files. The sidebar holds app navigation, search, and rescanning controls. Movies and series appear in the main collection grid, similar to Jellyfin-style library browsing. Selecting a collection card opens that movie or series folder with its metadata, seasons, episodes, and playable files.
Episodes are shown as thumbnail cards. h-player generates cached animated WebP thumbnails with `ffmpeg` when a thumbnail does not already exist, including thumbnails for newly added episodes discovered during scans. Clicking an episode opens it in the fullscreen player. `Play all` starts a playlist for the selected movie or series and advances through each episode in order.
## Folder Layout ## Folder Layout
Place media in this shape: Place media in this shape:
+1 -1
View File
@@ -1 +1 @@
1.4.2 1.5.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "h-player", "name": "h-player",
"version": "1.4.2", "version": "1.5.0",
"private": true, "private": true,
"description": "A small private web app for browsing a folder-based video collection.", "description": "A small private web app for browsing a folder-based video collection.",
"scripts": { "scripts": {
+83 -10
View File
@@ -7,12 +7,20 @@ const state = {
const els = { const els = {
libraryBadge: document.querySelector("#libraryBadge"), libraryBadge: document.querySelector("#libraryBadge"),
libraryPath: document.querySelector("#libraryPath"),
backBtn: document.querySelector("#backBtn"), backBtn: document.querySelector("#backBtn"),
rescanBtn: document.querySelector("#rescanBtn"), rescanBtn: document.querySelector("#rescanBtn"),
searchInput: document.querySelector("#searchInput"), searchInput: document.querySelector("#searchInput"),
details: document.querySelector("#details"), details: document.querySelector("#details"),
titleCardTemplate: document.querySelector("#titleCardTemplate") titleCardTemplate: document.querySelector("#titleCardTemplate"),
playerOverlay: document.querySelector("#playerOverlay"),
playerVideo: document.querySelector("#playerVideo"),
closePlayerBtn: document.querySelector("#closePlayerBtn"),
playerNowPlaying: document.querySelector("#playerNowPlaying")
};
const player = {
playlist: [],
index: 0
}; };
function bytes(value) { function bytes(value) {
@@ -39,7 +47,6 @@ async function api(path, options = {}) {
async function loadLibrary() { async function loadLibrary() {
const data = await api("/api/library"); const data = await api("/api/library");
state.titles = data.titles; state.titles = data.titles;
els.libraryPath.textContent = data.libraryDir;
els.libraryBadge.textContent = `${state.titles.length} title${state.titles.length === 1 ? "" : "s"}`; els.libraryBadge.textContent = `${state.titles.length} title${state.titles.length === 1 ? "" : "s"}`;
if (!state.titles.some((title) => title.id === state.activeId)) { if (!state.titles.some((title) => title.id === state.activeId)) {
state.activeId = ""; state.activeId = "";
@@ -123,6 +130,7 @@ function renderDetails(title) {
<button id="editMetaBtn" type="button">Edit</button> <button id="editMetaBtn" type="button">Edit</button>
<button id="fetchMetadataBtn" type="button">Metadata</button> <button id="fetchMetadataBtn" type="button">Metadata</button>
<button id="makeThumbBtn" type="button">Thumbnail</button> <button id="makeThumbBtn" type="button">Thumbnail</button>
<button id="playAllBtn" class="primary" type="button">Play all</button>
</div> </div>
</div> </div>
<div id="metaView" class="meta-view"> <div id="metaView" class="meta-view">
@@ -139,10 +147,16 @@ function renderDetails(title) {
</div> </div>
`; `;
wireEpisodeCards(title);
document.querySelector("#editMetaBtn").addEventListener("click", () => { document.querySelector("#editMetaBtn").addEventListener("click", () => {
renderMetadataForm(title); renderMetadataForm(title);
}); });
document.querySelector("#playAllBtn").addEventListener("click", () => {
startPlaylist(flattenVideos(title), 0);
});
document.querySelector("#makeThumbBtn").addEventListener("click", async (event) => { document.querySelector("#makeThumbBtn").addEventListener("click", async (event) => {
event.currentTarget.disabled = true; event.currentTarget.disabled = true;
event.currentTarget.textContent = "Working"; event.currentTarget.textContent = "Working";
@@ -220,19 +234,65 @@ function renderSeason(season) {
<h3>${escapeHtml(season.name)}</h3> <h3>${escapeHtml(season.name)}</h3>
<div class="episodes"> <div class="episodes">
${season.videos.map((video) => ` ${season.videos.map((video) => `
<article class="episode"> <button class="episode" type="button" data-video-id="${escapeAttr(video.id)}">
<div> <span class="episode__thumb">
<strong>${escapeHtml(video.name)}</strong> <img src="${escapeAttr(video.thumbnail)}" alt="" loading="lazy" onerror="this.hidden=true">
<span>${escapeHtml(video.relativePath)} · ${bytes(video.size)}</span> <span class="episode__play">Play</span>
</div> </span>
<video controls preload="metadata" src="/video/${encodeURIComponent(video.id)}"></video> <span class="episode__name">${escapeHtml(video.name)}</span>
</article> <span class="episode__meta">${escapeHtml(video.relativePath)} · ${bytes(video.size)}</span>
</button>
`).join("")} `).join("")}
</div> </div>
</section> </section>
`; `;
} }
function flattenVideos(title) {
return title.seasons.flatMap((season) => season.videos);
}
function wireEpisodeCards(title) {
const videos = flattenVideos(title);
document.querySelectorAll("[data-video-id]").forEach((node) => {
node.addEventListener("click", () => {
const index = videos.findIndex((video) => video.id === node.dataset.videoId);
startPlaylist(videos, Math.max(index, 0));
});
});
}
function startPlaylist(videos, index = 0) {
if (!videos.length) return;
player.playlist = videos;
player.index = index;
els.playerOverlay.classList.remove("hidden");
playCurrent();
const fullscreenTarget = els.playerOverlay;
if (fullscreenTarget.requestFullscreen) {
fullscreenTarget.requestFullscreen().catch(() => {});
}
}
function playCurrent() {
const video = player.playlist[player.index];
if (!video) {
closePlayer();
return;
}
els.playerVideo.src = `/video/${encodeURIComponent(video.id)}`;
els.playerNowPlaying.textContent = `${video.name} · ${player.index + 1} / ${player.playlist.length}`;
els.playerVideo.play().catch(() => {});
}
function closePlayer() {
els.playerVideo.pause();
els.playerVideo.removeAttribute("src");
els.playerVideo.load();
els.playerOverlay.classList.add("hidden");
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
}
function initials(value) { function initials(value) {
return value.split(/\s+/).slice(0, 2).map((part) => part[0] || "").join("").toUpperCase(); return value.split(/\s+/).slice(0, 2).map((part) => part[0] || "").join("").toUpperCase();
} }
@@ -262,6 +322,19 @@ els.backBtn.addEventListener("click", () => {
render(); render();
}); });
els.closePlayerBtn.addEventListener("click", closePlayer);
els.playerVideo.addEventListener("ended", () => {
player.index += 1;
playCurrent();
});
document.addEventListener("fullscreenchange", () => {
if (!document.fullscreenElement && !els.playerOverlay.classList.contains("hidden")) {
closePlayer();
}
});
els.rescanBtn.addEventListener("click", async () => { els.rescanBtn.addEventListener("click", async () => {
els.rescanBtn.disabled = true; els.rescanBtn.disabled = true;
els.rescanBtn.textContent = "Scanning"; els.rescanBtn.textContent = "Scanning";
+82 -37
View File
@@ -246,28 +246,6 @@ label {
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.sidebar-note {
display: grid;
gap: 6px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
padding: 14px;
}
.sidebar-note__label {
color: #ddd0ff;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.sidebar-note p {
color: var(--muted);
font-size: 13px;
}
.collection-grid { .collection-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
@@ -524,41 +502,108 @@ label {
.episodes { .episodes {
display: grid; display: grid;
gap: 0.9rem; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
} }
.episode { .episode {
display: grid; display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(280px, 520px); gap: 10px;
gap: 1rem; align-items: start;
align-items: center; min-height: auto;
padding: 0.85rem; padding: 12px;
text-align: left;
box-shadow: none; box-shadow: none;
} }
.episode div { .episode:hover {
display: grid; border-color: var(--line-strong);
gap: 0.25rem; background: linear-gradient(180deg, rgba(38, 28, 64, 0.94) 0%, rgba(19, 14, 32, 0.98) 100%);
min-width: 0;
} }
.episode strong, .episode__thumb {
.episode span { position: relative;
aspect-ratio: 16 / 9;
border: 1px solid rgba(157, 123, 255, 0.16);
border-radius: 14px;
background:
radial-gradient(circle at top, rgba(157, 123, 255, 0.22), transparent 44%),
linear-gradient(180deg, #221734 0%, #0c0915 100%);
overflow: hidden;
}
.episode__thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.episode__play {
position: absolute;
right: 10px;
bottom: 10px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 999px;
padding: 5px 9px;
color: var(--text);
background: rgba(9, 6, 17, 0.74);
font-size: 12px;
font-weight: 800;
}
.episode__name {
color: var(--text);
font-weight: 800;
}
.episode__meta {
color: var(--muted);
font-size: 13px;
}
.episode__name,
.episode__meta {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
video { .player-overlay {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
grid-template-rows: 1fr auto;
place-items: center;
gap: 12px;
padding: 24px;
background: #050308;
}
.player-video {
width: 100%; width: 100%;
max-height: 320px; max-width: min(100%, 1600px);
max-height: calc(100vh - 120px);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 16px; border-radius: 16px;
background: #050308; background: #050308;
} }
.player-close {
position: fixed;
top: 18px;
right: 18px;
z-index: 21;
}
.player-now-playing {
color: var(--muted);
font-size: 13px;
text-align: center;
}
@media (max-width: 980px) { @media (max-width: 980px) {
.app, .app,
.hero, .hero {
.episode {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
+6 -6
View File
@@ -26,17 +26,12 @@
<span>Search collection</span> <span>Search collection</span>
<input id="searchInput" type="search" placeholder="Title, folder, file" autocomplete="off"> <input id="searchInput" type="search" placeholder="Title, folder, file" autocomplete="off">
</label> </label>
<div class="sidebar-note">
<span class="sidebar-note__label">Library view</span>
<p>Browse folders from the main collection grid.</p>
</div>
</aside> </aside>
<main class="layout"> <main class="layout">
<header class="toolbar"> <header class="toolbar">
<div> <div>
<h2>Collection</h2> <h2>Collection</h2>
<p id="libraryPath" class="muted"></p>
</div> </div>
<button id="backBtn" class="ghost hidden" type="button">Collection</button> <button id="backBtn" class="ghost hidden" type="button">Collection</button>
</header> </header>
@@ -44,12 +39,17 @@
<section id="details" class="details"> <section id="details" class="details">
<div class="empty"> <div class="empty">
<h2>No titles found</h2> <h2>No titles found</h2>
<p>Mount or create a Library folder with one folder per movie or series.</p>
</div> </div>
</section> </section>
</main> </main>
</div> </div>
<div id="playerOverlay" class="player-overlay hidden">
<button id="closePlayerBtn" class="player-close" type="button">Close</button>
<video id="playerVideo" class="player-video" controls autoplay></video>
<div id="playerNowPlaying" class="player-now-playing"></div>
</div>
<template id="titleCardTemplate"> <template id="titleCardTemplate">
<button class="title-card" type="button"> <button class="title-card" type="button">
<span class="title-card__poster"> <span class="title-card__poster">
+79
View File
@@ -18,6 +18,7 @@ const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "";
const ANIDB_CLIENT_NAME = process.env.ANIDB_CLIENT_NAME || ""; const ANIDB_CLIENT_NAME = process.env.ANIDB_CLIENT_NAME || "";
const ANIDB_CLIENT_VERSION = Number(process.env.ANIDB_CLIENT_VERSION || 1); const ANIDB_CLIENT_VERSION = Number(process.env.ANIDB_CLIENT_VERSION || 1);
const THUMB_DIR = path.join(DATA_DIR, "thumbnails"); const THUMB_DIR = path.join(DATA_DIR, "thumbnails");
const VIDEO_THUMB_DIR = path.join(DATA_DIR, "episode-thumbnails");
const CACHE_DIR = path.join(DATA_DIR, "cache"); const CACHE_DIR = path.join(DATA_DIR, "cache");
const DB_FILE = path.join(DATA_DIR, "library.sqlite"); const DB_FILE = path.join(DATA_DIR, "library.sqlite");
const LEGACY_META_FILE = path.join(DATA_DIR, "metadata.json"); const LEGACY_META_FILE = path.join(DATA_DIR, "metadata.json");
@@ -40,6 +41,8 @@ const MIME_BY_EXT = {
let db; let db;
let lastScan = []; let lastScan = [];
const pendingVideoThumbs = new Set();
const failedVideoThumbs = new Set();
function send(res, status, body, type = "application/json") { function send(res, status, body, type = "application/json") {
const payload = type === "application/json" ? JSON.stringify(body) : body; const payload = type === "application/json" ? JSON.stringify(body) : body;
@@ -102,6 +105,7 @@ function slugFor(value) {
async function ensureStorage() { async function ensureStorage() {
await fsp.mkdir(THUMB_DIR, { recursive: true }); await fsp.mkdir(THUMB_DIR, { recursive: true });
await fsp.mkdir(VIDEO_THUMB_DIR, { recursive: true });
await fsp.mkdir(CACHE_DIR, { recursive: true }); await fsp.mkdir(CACHE_DIR, { recursive: true });
db = new DatabaseSync(DB_FILE); db = new DatabaseSync(DB_FILE);
db.exec(` db.exec(`
@@ -323,6 +327,7 @@ async function scanLibrary() {
const meta = getTitleMetadata(id, entry.name); const meta = getTitleMetadata(id, entry.name);
const seasons = groupSeasons(videos); const seasons = groupSeasons(videos);
await attachVideoThumbnails(id, titlePath, videos);
titles.push({ titles.push({
id, id,
folder: entry.name, folder: entry.name,
@@ -342,6 +347,66 @@ async function scanLibrary() {
return lastScan; return lastScan;
} }
async function attachVideoThumbnails(titleId, titlePath, videos) {
for (const video of videos) {
const outputName = `${titleId}-${video.id}.webp`;
const output = path.join(VIDEO_THUMB_DIR, outputName);
video.thumbnail = `/episode-thumbs/${outputName}`;
try {
await fsp.access(output, fs.constants.R_OK);
} catch {
scheduleVideoThumbnail(titleId, titlePath, video);
}
}
}
function scheduleVideoThumbnail(titleId, titlePath, video) {
const key = `${titleId}:${video.id}`;
if (pendingVideoThumbs.has(key) || failedVideoThumbs.has(key)) return;
pendingVideoThumbs.add(key);
generateVideoThumbnail(titleId, titlePath, video)
.catch((err) => {
failedVideoThumbs.add(key);
console.error(`Episode thumbnail failed for ${video.relativePath}: ${err.message}`);
})
.finally(() => pendingVideoThumbs.delete(key));
}
async function generateVideoThumbnail(titleId, titlePath, video) {
const source = path.resolve(titlePath, video.relativePath);
if (!isInside(titlePath, source)) throw new Error("Invalid video path");
const output = path.join(VIDEO_THUMB_DIR, `${titleId}-${video.id}.webp`);
const tmpOutput = `${output}.part`;
try {
await fsp.unlink(tmpOutput);
} catch (err) {
if (err.code !== "ENOENT") throw err;
}
await new Promise((resolve, reject) => {
const ffmpeg = spawn("ffmpeg", [
"-y",
"-ss", "00:00:12",
"-t", "4",
"-i", source,
"-vf", "fps=6,scale=360:-1:flags=lanczos",
"-loop", "0",
"-an",
"-f", "webp",
tmpOutput
], { stdio: "ignore" });
ffmpeg.on("error", reject);
ffmpeg.on("close", (code) => {
if (code === 0) resolve();
else reject(new Error("ffmpeg could not generate an animated thumbnail"));
});
});
await fsp.rename(tmpOutput, output);
}
function groupSeasons(videos) { function groupSeasons(videos) {
const groups = new Map(); const groups = new Map();
for (const video of videos) { for (const video of videos) {
@@ -686,6 +751,19 @@ async function serveThumbnail(res, name) {
} }
} }
async function serveEpisodeThumbnail(res, name) {
const filePath = path.resolve(VIDEO_THUMB_DIR, safeDecode(name));
if (!isInside(VIDEO_THUMB_DIR, filePath)) return send(res, 403, { error: "Forbidden" });
try {
const data = await fsp.readFile(filePath);
res.writeHead(200, { "content-type": "image/webp", "cache-control": "private, max-age=86400" });
res.end(data);
} catch (err) {
if (err.code === "ENOENT") send(res, 404, { error: "Not found" });
else throw err;
}
}
async function streamVideo(req, res, videoId) { async function streamVideo(req, res, videoId) {
const match = findVideo(videoId); const match = findVideo(videoId);
if (!match) return send(res, 404, { error: "Video not found" }); if (!match) return send(res, 404, { error: "Video not found" });
@@ -733,6 +811,7 @@ async function route(req, res) {
if (pathname === "/" || pathname === "/index.html") return serveStatic(req, res, "/index.html"); if (pathname === "/" || pathname === "/index.html") return serveStatic(req, res, "/index.html");
if (pathname.startsWith("/assets/")) return serveStatic(req, res, pathname); if (pathname.startsWith("/assets/")) return serveStatic(req, res, pathname);
if (pathname.startsWith("/thumbs/")) return serveThumbnail(res, pathname.slice("/thumbs/".length)); if (pathname.startsWith("/thumbs/")) return serveThumbnail(res, pathname.slice("/thumbs/".length));
if (pathname.startsWith("/episode-thumbs/")) return serveEpisodeThumbnail(res, pathname.slice("/episode-thumbs/".length));
if (pathname === "/api/library" && req.method === "GET") { if (pathname === "/api/library" && req.method === "GET") {
return send(res, 200, { libraryDir: LIBRARY_DIR, titles: await scanLibrary() }); return send(res, 200, { libraryDir: LIBRARY_DIR, titles: await scanLibrary() });