Move library browsing to main grid
This commit is contained in:
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user