Move library browsing to main grid

This commit is contained in:
Dymas
2026-09-15 15:37:43 +02:00
parent a2b26f6680
commit 9f39478018
7 changed files with 158 additions and 45 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
## 1.3.0 - 2026-09-15
- Moved movie and series browsing out of the sidebar into a Jellyfin-style main collection grid.
- Added click-through collection cards that open the selected movie or series folder details and episodes in the main panel.
- Added a collection return action and kept sidebar controls focused on navigation, search, and rescans.
## 1.2.1 - 2026-09-15 ## 1.2.1 - 2026-09-15
- Set the Docker Compose container host binding to `0.0.0.0` so Docker's localhost port mapping can reach h-player while keeping the host publish on `127.0.0.1`. - Set the Docker Compose container host binding to `0.0.0.0` so Docker's localhost port mapping can reach h-player while keeping the host publish on `127.0.0.1`.
+2
View File
@@ -2,6 +2,8 @@
h-player is a small local web app for browsing a folder-based private video collection. The interface follows Kaizoku's dark purple glass-panel design, including the same card gradients, rounded controls, cover fallback treatment, and accent colors. The app remains a standalone private library browser and does not integrate with Plex, Jellyfin, Kodi, or any external metadata provider. h-player is a small local web app for browsing a folder-based private video collection. The interface follows Kaizoku's dark purple glass-panel design, including the same card gradients, rounded controls, cover fallback treatment, and accent colors. The app remains a standalone private library browser and does not integrate with Plex, Jellyfin, Kodi, or any external metadata provider.
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.
## Folder Layout ## Folder Layout
Place media in this shape: Place media in this shape:
+1 -1
View File
@@ -1 +1 @@
1.2.1 1.3.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "h-player", "name": "h-player",
"version": "1.2.1", "version": "1.3.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": {
+50 -26
View File
@@ -1,17 +1,18 @@
const state = { const state = {
titles: [], titles: [],
activeId: "", activeId: "",
query: "" query: "",
view: "collection"
}; };
const els = { const els = {
libraryBadge: document.querySelector("#libraryBadge"), libraryBadge: document.querySelector("#libraryBadge"),
libraryPath: document.querySelector("#libraryPath"), libraryPath: document.querySelector("#libraryPath"),
backBtn: document.querySelector("#backBtn"),
rescanBtn: document.querySelector("#rescanBtn"), rescanBtn: document.querySelector("#rescanBtn"),
searchInput: document.querySelector("#searchInput"), searchInput: document.querySelector("#searchInput"),
titleList: document.querySelector("#titleList"),
details: document.querySelector("#details"), details: document.querySelector("#details"),
titleButtonTemplate: document.querySelector("#titleButtonTemplate") titleCardTemplate: document.querySelector("#titleCardTemplate")
}; };
function bytes(value) { function bytes(value) {
@@ -40,9 +41,9 @@ async function loadLibrary() {
state.titles = data.titles; state.titles = data.titles;
els.libraryPath.textContent = data.libraryDir; 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.activeId && state.titles.length) state.activeId = state.titles[0].id;
if (!state.titles.some((title) => title.id === state.activeId)) { if (!state.titles.some((title) => title.id === state.activeId)) {
state.activeId = state.titles[0]?.id || ""; state.activeId = "";
state.view = "collection";
} }
render(); render();
} }
@@ -61,28 +62,18 @@ function filteredTitles() {
} }
function render() { function render() {
renderTitleList(); const activeTitle = state.titles.find((item) => item.id === state.activeId);
renderDetails(); els.backBtn.classList.toggle("hidden", state.view !== "details");
if (state.view === "details" && activeTitle) {
renderDetails(activeTitle);
return;
}
renderCollection();
} }
function renderTitleList() { function renderCollection() {
els.titleList.innerHTML = ""; const titles = filteredTitles();
for (const title of filteredTitles()) { if (!titles.length) {
const node = els.titleButtonTemplate.content.firstElementChild.cloneNode(true);
node.classList.toggle("is-active", title.id === state.activeId);
node.querySelector(".title-button__name").textContent = title.title;
node.querySelector(".title-button__meta").textContent = `${title.count} file${title.count === 1 ? "" : "s"} · ${bytes(title.size)}`;
node.addEventListener("click", () => {
state.activeId = title.id;
render();
});
els.titleList.append(node);
}
}
function renderDetails() {
const title = state.titles.find((item) => item.id === state.activeId);
if (!title) {
els.details.innerHTML = ` els.details.innerHTML = `
<div class="empty"> <div class="empty">
<h2>No titles found</h2> <h2>No titles found</h2>
@@ -92,6 +83,33 @@ function renderDetails() {
return; return;
} }
els.details.innerHTML = `
<div class="collection-grid" id="collectionGrid"></div>
`;
const grid = document.querySelector("#collectionGrid");
for (const title of titles) {
const node = els.titleCardTemplate.content.firstElementChild.cloneNode(true);
node.querySelector(".title-card__name").textContent = title.title;
node.querySelector(".title-card__meta").textContent = `${title.count} file${title.count === 1 ? "" : "s"} · ${bytes(title.size)}`;
node.querySelector(".title-card__fallback").textContent = initials(title.title);
const poster = node.querySelector(".title-card__poster");
const image = node.querySelector("img");
if (title.thumbnail) {
poster.classList.add("has-image");
image.src = title.thumbnail;
image.alt = "";
}
node.addEventListener("click", () => {
state.activeId = title.id;
state.view = "details";
render();
});
grid.append(node);
}
}
function renderDetails(title) {
els.details.innerHTML = ` els.details.innerHTML = `
<div class="hero"> <div class="hero">
<div class="poster">${title.thumbnail ? `<img src="${escapeAttr(title.thumbnail)}" alt="">` : `<span>${initials(title.title)}</span>`}</div> <div class="poster">${title.thumbnail ? `<img src="${escapeAttr(title.thumbnail)}" alt="">` : `<span>${initials(title.title)}</span>`}</div>
@@ -192,7 +210,13 @@ function escapeAttr(value) {
els.searchInput.addEventListener("input", (event) => { els.searchInput.addEventListener("input", (event) => {
state.query = event.target.value; state.query = event.target.value;
renderTitleList(); state.view = "collection";
render();
});
els.backBtn.addEventListener("click", () => {
state.view = "collection";
render();
}); });
els.rescanBtn.addEventListener("click", async () => { els.rescanBtn.addEventListener("click", async () => {
+86 -11
View File
@@ -75,6 +75,14 @@ button:disabled {
transform: none; transform: none;
} }
button.ghost {
background: transparent;
}
.hidden {
display: none !important;
}
input, input,
textarea { textarea {
width: 100%; width: 100%;
@@ -170,7 +178,7 @@ h3 {
.shell-note, .shell-note,
.muted, .muted,
.title-button__meta, .title-card__meta,
.episode span, .episode span,
.summary p, .summary p,
label span { label span {
@@ -238,13 +246,36 @@ label {
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.title-list { .sidebar-note {
display: grid; 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));
gap: 12px; gap: 12px;
min-width: 0; min-width: 0;
} }
.title-button { .title-card {
display: grid; display: grid;
gap: 12px; gap: 12px;
width: 100%; width: 100%;
@@ -258,25 +289,65 @@ label {
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease; transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
} }
.title-button:hover { .title-card:hover {
border-color: var(--line-strong); border-color: var(--line-strong);
background: linear-gradient(180deg, rgba(38, 28, 64, 0.94) 0%, rgba(19, 14, 32, 0.98) 100%); background: linear-gradient(180deg, rgba(38, 28, 64, 0.94) 0%, rgba(19, 14, 32, 0.98) 100%);
transform: translateY(-2px); transform: translateY(-2px);
} }
.title-button.is-active { .title-card__poster {
border-color: rgba(157, 123, 255, 0.58); aspect-ratio: 3 / 4;
background: linear-gradient(180deg, rgba(48, 36, 80, 0.96), rgba(20, 15, 35, 0.96)); position: relative;
box-shadow: border: 1px solid rgba(157, 123, 255, 0.16);
inset 0 0 0 1px rgba(157, 123, 255, 0.16), border-radius: 16px;
0 18px 38px rgba(5, 2, 15, 0.32); background:
radial-gradient(circle at top, rgba(157, 123, 255, 0.28), transparent 44%),
linear-gradient(180deg, #221734 0%, #0c0915 100%);
overflow: hidden;
display: grid;
place-items: center;
} }
.title-button__name { .title-card__poster img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
opacity: 0;
transition: opacity 0.16s ease;
}
.title-card__poster.has-image img {
opacity: 1;
}
.title-card__fallback {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #d8e5ef;
font-size: 28px;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
background: linear-gradient(180deg, rgba(157, 123, 255, 0.24), rgba(9, 7, 15, 0.18));
}
.title-card__poster.has-image .title-card__fallback {
display: none;
}
.title-card__name {
font-weight: 800; font-weight: 800;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.title-card__meta {
color: var(--muted);
font-size: 13px;
}
.toolbar, .toolbar,
.details, .details,
.empty, .empty,
@@ -486,4 +557,8 @@ video {
.summary h2 { .summary h2 {
font-size: 2rem; font-size: 2rem;
} }
.collection-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
} }
+13 -7
View File
@@ -26,8 +26,10 @@
<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">
<div id="titleList" class="title-list" aria-label="Titles"></div> <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">
@@ -36,7 +38,7 @@
<h2>Collection</h2> <h2>Collection</h2>
<p id="libraryPath" class="muted"></p> <p id="libraryPath" class="muted"></p>
</div> </div>
<span class="badge">Local</span> <button id="backBtn" class="ghost hidden" type="button">Collection</button>
</header> </header>
<section id="details" class="details"> <section id="details" class="details">
@@ -48,10 +50,14 @@
</main> </main>
</div> </div>
<template id="titleButtonTemplate"> <template id="titleCardTemplate">
<button class="title-button" type="button"> <button class="title-card" type="button">
<span class="title-button__name"></span> <span class="title-card__poster">
<span class="title-button__meta"></span> <img alt="">
<span class="title-card__fallback"></span>
</span>
<span class="title-card__name"></span>
<span class="title-card__meta"></span>
</button> </button>
</template> </template>