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) { +
@@ -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)} -
- -
+ `).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 -

Collection

-

@@ -44,12 +39,17 @@

No titles found

-

Mount or create a Library folder with one folder per movie or series.

+ +