Animate episode thumbnails on hover
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 1.5.1 - 2026-09-15
|
||||
|
||||
- Changed episode thumbnails to show a still image by default and play the animated thumbnail only while hovered or focused.
|
||||
- Changed episodes from a grid into a compact list and moved `Play all` above the episode list.
|
||||
|
||||
## 1.5.0 - 2026-09-15
|
||||
|
||||
- Replaced inline episode video players with clickable episode cards using cached animated WebP thumbnails generated by ffmpeg.
|
||||
|
||||
@@ -4,7 +4,7 @@ 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.
|
||||
Episodes are shown as a compact list with cached thumbnails. h-player generates both still JPEG thumbnails and animated WebP thumbnails with `ffmpeg` when thumbnails do not already exist, including thumbnails for newly added episodes discovered during scans. The still image is shown by default, and the animated thumbnail plays while hovering or focusing an episode. Clicking an episode opens it in the fullscreen player. `Play all` sits above the episode list and starts a playlist for the selected movie or series.
|
||||
|
||||
## Folder Layout
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "h-player",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"private": true,
|
||||
"description": "A small private web app for browsing a folder-based video collection.",
|
||||
"scripts": {
|
||||
|
||||
+28
-2
@@ -130,7 +130,6 @@ function renderDetails(title) {
|
||||
<button id="editMetaBtn" type="button">Edit</button>
|
||||
<button id="fetchMetadataBtn" type="button">Metadata</button>
|
||||
<button id="makeThumbBtn" type="button">Thumbnail</button>
|
||||
<button id="playAllBtn" class="primary" type="button">Play all</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="metaView" class="meta-view">
|
||||
@@ -142,6 +141,9 @@ function renderDetails(title) {
|
||||
<div id="metaEditMount"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="episode-toolbar">
|
||||
<button id="playAllBtn" class="primary" type="button">Play all</button>
|
||||
</div>
|
||||
<div class="seasons">
|
||||
${title.seasons.map(renderSeason).join("")}
|
||||
</div>
|
||||
@@ -236,7 +238,14 @@ function renderSeason(season) {
|
||||
${season.videos.map((video) => `
|
||||
<button class="episode" type="button" data-video-id="${escapeAttr(video.id)}">
|
||||
<span class="episode__thumb">
|
||||
<img src="${escapeAttr(video.thumbnail)}" alt="" loading="lazy" onerror="this.hidden=true">
|
||||
<img
|
||||
src="${escapeAttr(video.thumbnail)}"
|
||||
data-still="${escapeAttr(video.thumbnail)}"
|
||||
data-animated="${escapeAttr(video.animatedThumbnail)}"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onerror="this.hidden=true"
|
||||
>
|
||||
<span class="episode__play">Play</span>
|
||||
</span>
|
||||
<span class="episode__name">${escapeHtml(video.name)}</span>
|
||||
@@ -255,6 +264,23 @@ function flattenVideos(title) {
|
||||
function wireEpisodeCards(title) {
|
||||
const videos = flattenVideos(title);
|
||||
document.querySelectorAll("[data-video-id]").forEach((node) => {
|
||||
const image = node.querySelector("img");
|
||||
const showAnimated = () => {
|
||||
if (image?.dataset.animated && image.src !== new URL(image.dataset.animated, window.location.href).href) {
|
||||
image.hidden = false;
|
||||
image.src = image.dataset.animated;
|
||||
}
|
||||
};
|
||||
const showStill = () => {
|
||||
if (image?.dataset.still && image.src !== new URL(image.dataset.still, window.location.href).href) {
|
||||
image.hidden = false;
|
||||
image.src = image.dataset.still;
|
||||
}
|
||||
};
|
||||
node.addEventListener("mouseenter", showAnimated);
|
||||
node.addEventListener("focus", showAnimated);
|
||||
node.addEventListener("mouseleave", showStill);
|
||||
node.addEventListener("blur", showStill);
|
||||
node.addEventListener("click", () => {
|
||||
const index = videos.findIndex((video) => video.id === node.dataset.videoId);
|
||||
startPlaylist(videos, Math.max(index, 0));
|
||||
|
||||
@@ -483,6 +483,12 @@ label {
|
||||
.seasons {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.episode-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -502,16 +508,16 @@ label {
|
||||
|
||||
.episodes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.episode {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
grid-template-columns: 168px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
min-height: auto;
|
||||
padding: 12px;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -555,11 +561,13 @@ label {
|
||||
.episode__name {
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.episode__meta {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.episode__name,
|
||||
@@ -624,6 +632,10 @@ label {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.episode {
|
||||
grid-template-columns: 132px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
@@ -649,4 +661,8 @@ label {
|
||||
.collection-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.episode {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,11 +349,15 @@ async function scanLibrary() {
|
||||
|
||||
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}`;
|
||||
const stillName = `${titleId}-${video.id}.jpg`;
|
||||
const animatedName = `${titleId}-${video.id}.webp`;
|
||||
const stillOutput = path.join(VIDEO_THUMB_DIR, stillName);
|
||||
const animatedOutput = path.join(VIDEO_THUMB_DIR, animatedName);
|
||||
video.thumbnail = `/episode-thumbs/${stillName}`;
|
||||
video.animatedThumbnail = `/episode-thumbs/${animatedName}`;
|
||||
try {
|
||||
await fsp.access(output, fs.constants.R_OK);
|
||||
await fsp.access(stillOutput, fs.constants.R_OK);
|
||||
await fsp.access(animatedOutput, fs.constants.R_OK);
|
||||
} catch {
|
||||
scheduleVideoThumbnail(titleId, titlePath, video);
|
||||
}
|
||||
@@ -376,13 +380,38 @@ 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`;
|
||||
const stillOutput = path.join(VIDEO_THUMB_DIR, `${titleId}-${video.id}.jpg`);
|
||||
const animatedOutput = path.join(VIDEO_THUMB_DIR, `${titleId}-${video.id}.webp`);
|
||||
const tmpStill = `${stillOutput}.part`;
|
||||
const tmpAnimated = `${animatedOutput}.part`;
|
||||
try {
|
||||
await fsp.unlink(tmpOutput);
|
||||
await fsp.unlink(tmpStill);
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") throw err;
|
||||
}
|
||||
try {
|
||||
await fsp.unlink(tmpAnimated);
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") throw err;
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const ffmpeg = spawn("ffmpeg", [
|
||||
"-y",
|
||||
"-ss", "00:00:12",
|
||||
"-i", source,
|
||||
"-frames:v", "1",
|
||||
"-vf", "scale=360:-1:flags=lanczos",
|
||||
"-f", "image2",
|
||||
tmpStill
|
||||
], { stdio: "ignore" });
|
||||
|
||||
ffmpeg.on("error", reject);
|
||||
ffmpeg.on("close", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error("ffmpeg could not generate a still thumbnail"));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const ffmpeg = spawn("ffmpeg", [
|
||||
@@ -394,7 +423,7 @@ async function generateVideoThumbnail(titleId, titlePath, video) {
|
||||
"-loop", "0",
|
||||
"-an",
|
||||
"-f", "webp",
|
||||
tmpOutput
|
||||
tmpAnimated
|
||||
], { stdio: "ignore" });
|
||||
|
||||
ffmpeg.on("error", reject);
|
||||
@@ -404,7 +433,8 @@ async function generateVideoThumbnail(titleId, titlePath, video) {
|
||||
});
|
||||
});
|
||||
|
||||
await fsp.rename(tmpOutput, output);
|
||||
await fsp.rename(tmpStill, stillOutput);
|
||||
await fsp.rename(tmpAnimated, animatedOutput);
|
||||
}
|
||||
|
||||
function groupSeasons(videos) {
|
||||
@@ -756,7 +786,8 @@ async function serveEpisodeThumbnail(res, 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" });
|
||||
const type = path.extname(filePath).toLowerCase() === ".webp" ? "image/webp" : "image/jpeg";
|
||||
res.writeHead(200, { "content-type": type, "cache-control": "private, max-age=86400" });
|
||||
res.end(data);
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") send(res, 404, { error: "Not found" });
|
||||
|
||||
Reference in New Issue
Block a user