diff --git a/CHANGELOG.md b/CHANGELOG.md
index 61974a8..eb93151 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# 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
- Hid display title and thumbnail path from the read-only metadata view; those fields now appear only while editing metadata.
diff --git a/README.md b/README.md
index 53f4d5a..6ab793e 100644
--- a/README.md
+++ b/README.md
@@ -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.
+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
Place media in this shape:
diff --git a/VERSION b/VERSION
index 9df886c..bc80560 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.4.2
+1.5.0
diff --git a/package.json b/package.json
index 2ea4cb1..3aa94f7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "h-player",
- "version": "1.4.2",
+ "version": "1.5.0",
"private": true,
"description": "A small private web app for browsing a folder-based video collection.",
"scripts": {
diff --git a/public/assets/app.js b/public/assets/app.js
index 87ea0d1..d985b42 100644
--- a/public/assets/app.js
+++ b/public/assets/app.js
@@ -7,12 +7,20 @@ const state = {
const els = {
libraryBadge: document.querySelector("#libraryBadge"),
- libraryPath: document.querySelector("#libraryPath"),
backBtn: document.querySelector("#backBtn"),
rescanBtn: document.querySelector("#rescanBtn"),
searchInput: document.querySelector("#searchInput"),
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) {
@@ -39,7 +47,6 @@ async function api(path, options = {}) {
async function loadLibrary() {
const data = await api("/api/library");
state.titles = data.titles;
- els.libraryPath.textContent = data.libraryDir;
els.libraryBadge.textContent = `${state.titles.length} title${state.titles.length === 1 ? "" : "s"}`;
if (!state.titles.some((title) => title.id === state.activeId)) {
state.activeId = "";
@@ -123,6 +130,7 @@ function renderDetails(title) {
Edit
Metadata
Thumbnail
+ Play all
@@ -139,10 +147,16 @@ function renderDetails(title) {
`;
+ wireEpisodeCards(title);
+
document.querySelector("#editMetaBtn").addEventListener("click", () => {
renderMetadataForm(title);
});
+ document.querySelector("#playAllBtn").addEventListener("click", () => {
+ startPlaylist(flattenVideos(title), 0);
+ });
+
document.querySelector("#makeThumbBtn").addEventListener("click", async (event) => {
event.currentTarget.disabled = true;
event.currentTarget.textContent = "Working";
@@ -220,19 +234,65 @@ function renderSeason(season) {
${escapeHtml(season.name)}
${season.videos.map((video) => `
-
-
- ${escapeHtml(video.name)}
- ${escapeHtml(video.relativePath)} · ${bytes(video.size)}
-
-
-
+
+
+
+ Play
+
+ ${escapeHtml(video.name)}
+ ${escapeHtml(video.relativePath)} · ${bytes(video.size)}
+
`).join("")}
`;
}
+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) {
return value.split(/\s+/).slice(0, 2).map((part) => part[0] || "").join("").toUpperCase();
}
@@ -262,6 +322,19 @@ els.backBtn.addEventListener("click", () => {
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.disabled = true;
els.rescanBtn.textContent = "Scanning";
diff --git a/public/assets/styles.css b/public/assets/styles.css
index 5bfdc46..a4de6e5 100644
--- a/public/assets/styles.css
+++ b/public/assets/styles.css
@@ -246,28 +246,6 @@ label {
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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
@@ -524,41 +502,108 @@ label {
.episodes {
display: grid;
- gap: 0.9rem;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 12px;
}
.episode {
display: grid;
- grid-template-columns: minmax(180px, 1fr) minmax(280px, 520px);
- gap: 1rem;
- align-items: center;
- padding: 0.85rem;
+ gap: 10px;
+ align-items: start;
+ min-height: auto;
+ padding: 12px;
+ text-align: left;
box-shadow: none;
}
-.episode div {
- display: grid;
- gap: 0.25rem;
- min-width: 0;
+.episode:hover {
+ border-color: var(--line-strong);
+ background: linear-gradient(180deg, rgba(38, 28, 64, 0.94) 0%, rgba(19, 14, 32, 0.98) 100%);
}
-.episode strong,
-.episode span {
+.episode__thumb {
+ 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;
}
-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%;
- max-height: 320px;
+ max-width: min(100%, 1600px);
+ max-height: calc(100vh - 120px);
border: 1px solid var(--line);
border-radius: 16px;
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) {
.app,
- .hero,
- .episode {
+ .hero {
grid-template-columns: 1fr;
}
diff --git a/public/index.html b/public/index.html
index 4f8753e..60f983e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -26,17 +26,12 @@
Search collection
-
@@ -44,12 +39,17 @@
No titles found
-
Mount or create a Library folder with one folder per movie or series.
+
+
diff --git a/server.js b/server.js
index 45f51c5..2f394d3 100644
--- a/server.js
+++ b/server.js
@@ -18,6 +18,7 @@ const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "";
const ANIDB_CLIENT_NAME = process.env.ANIDB_CLIENT_NAME || "";
const ANIDB_CLIENT_VERSION = Number(process.env.ANIDB_CLIENT_VERSION || 1);
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 DB_FILE = path.join(DATA_DIR, "library.sqlite");
const LEGACY_META_FILE = path.join(DATA_DIR, "metadata.json");
@@ -40,6 +41,8 @@ const MIME_BY_EXT = {
let db;
let lastScan = [];
+const pendingVideoThumbs = new Set();
+const failedVideoThumbs = new Set();
function send(res, status, body, type = "application/json") {
const payload = type === "application/json" ? JSON.stringify(body) : body;
@@ -102,6 +105,7 @@ function slugFor(value) {
async function ensureStorage() {
await fsp.mkdir(THUMB_DIR, { recursive: true });
+ await fsp.mkdir(VIDEO_THUMB_DIR, { recursive: true });
await fsp.mkdir(CACHE_DIR, { recursive: true });
db = new DatabaseSync(DB_FILE);
db.exec(`
@@ -323,6 +327,7 @@ async function scanLibrary() {
const meta = getTitleMetadata(id, entry.name);
const seasons = groupSeasons(videos);
+ await attachVideoThumbnails(id, titlePath, videos);
titles.push({
id,
folder: entry.name,
@@ -342,6 +347,66 @@ async function scanLibrary() {
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) {
const groups = new Map();
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) {
const match = findVideo(videoId);
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.startsWith("/assets/")) return serveStatic(req, res, pathname);
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") {
return send(res, 200, { libraryDir: LIBRARY_DIR, titles: await scanLibrary() });