Initial h-play app

This commit is contained in:
Dymas
2026-09-15 15:18:12 +02:00
commit e1dfdc7bd7
12 changed files with 1375 additions and 0 deletions
+212
View File
@@ -0,0 +1,212 @@
const state = {
titles: [],
activeId: "",
query: ""
};
const els = {
libraryBadge: document.querySelector("#libraryBadge"),
libraryPath: document.querySelector("#libraryPath"),
rescanBtn: document.querySelector("#rescanBtn"),
searchInput: document.querySelector("#searchInput"),
titleList: document.querySelector("#titleList"),
details: document.querySelector("#details"),
titleButtonTemplate: document.querySelector("#titleButtonTemplate")
};
function bytes(value) {
const units = ["B", "KB", "MB", "GB", "TB"];
let size = value;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${size.toFixed(size >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
}
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "content-type": "application/json" },
...options
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Request failed");
return data;
}
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.activeId && state.titles.length) state.activeId = state.titles[0].id;
if (!state.titles.some((title) => title.id === state.activeId)) {
state.activeId = state.titles[0]?.id || "";
}
render();
}
function filteredTitles() {
const query = state.query.toLowerCase();
if (!query) return state.titles;
return state.titles.filter((title) => {
return [
title.title,
title.folder,
title.description,
...title.videos.map((video) => video.relativePath)
].join(" ").toLowerCase().includes(query);
});
}
function render() {
renderTitleList();
renderDetails();
}
function renderTitleList() {
els.titleList.innerHTML = "";
for (const title of filteredTitles()) {
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 = `
<div class="empty">
<h2>No titles found</h2>
<p>Expected: Library/&lt;Movie or Series&gt;/filename.mp4 or Library/&lt;Series&gt;/Season 01/filename.mp4</p>
</div>
`;
return;
}
els.details.innerHTML = `
<div class="hero">
<div class="poster">${title.thumbnail ? `<img src="${escapeAttr(title.thumbnail)}" alt="">` : `<span>${initials(title.title)}</span>`}</div>
<div class="summary">
<div class="summary__header">
<div>
<h2>${escapeHtml(title.title)}</h2>
<p>${escapeHtml(title.folder)} · ${title.count} file${title.count === 1 ? "" : "s"} · ${bytes(title.size)}</p>
</div>
<button id="makeThumbBtn" type="button">Thumbnail</button>
</div>
<form id="metaForm" class="meta-form">
<label>
<span>Display title</span>
<input name="title" value="${escapeAttr(title.title)}">
</label>
<label>
<span>Thumbnail path or URL</span>
<input name="thumbnail" value="${escapeAttr(title.thumbnail)}" placeholder="/thumbs/example.jpg">
</label>
<label>
<span>Short description</span>
<textarea name="description" rows="5" placeholder="Add a private note or synopsis">${escapeHtml(title.description)}</textarea>
</label>
<button class="primary" type="submit">Save metadata</button>
</form>
</div>
</div>
<div class="seasons">
${title.seasons.map(renderSeason).join("")}
</div>
`;
document.querySelector("#metaForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
await api(`/api/titles/${encodeURIComponent(title.id)}`, {
method: "PATCH",
body: JSON.stringify({
title: form.get("title"),
thumbnail: form.get("thumbnail"),
description: form.get("description")
})
});
await loadLibrary();
});
document.querySelector("#makeThumbBtn").addEventListener("click", async (event) => {
event.currentTarget.disabled = true;
event.currentTarget.textContent = "Working";
try {
await api(`/api/titles/${encodeURIComponent(title.id)}/thumbnail`, { method: "POST", body: "{}" });
await loadLibrary();
} catch (err) {
alert(err.message);
event.currentTarget.disabled = false;
event.currentTarget.textContent = "Thumbnail";
}
});
}
function renderSeason(season) {
return `
<section class="season">
<h3>${escapeHtml(season.name)}</h3>
<div class="episodes">
${season.videos.map((video) => `
<article class="episode">
<div>
<strong>${escapeHtml(video.name)}</strong>
<span>${escapeHtml(video.relativePath)} · ${bytes(video.size)}</span>
</div>
<video controls preload="metadata" src="/video/${encodeURIComponent(video.id)}"></video>
</article>
`).join("")}
</div>
</section>
`;
}
function initials(value) {
return value.split(/\s+/).slice(0, 2).map((part) => part[0] || "").join("").toUpperCase();
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
})[char]);
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, "&#96;");
}
els.searchInput.addEventListener("input", (event) => {
state.query = event.target.value;
renderTitleList();
});
els.rescanBtn.addEventListener("click", async () => {
els.rescanBtn.disabled = true;
els.rescanBtn.textContent = "Scanning";
try {
await api("/api/rescan", { method: "POST", body: "{}" });
await loadLibrary();
} finally {
els.rescanBtn.disabled = false;
els.rescanBtn.textContent = "Rescan";
}
});
loadLibrary().catch((err) => {
els.details.innerHTML = `<div class="empty"><h2>Could not load library</h2><p>${escapeHtml(err.message)}</p></div>`;
});
+470
View File
@@ -0,0 +1,470 @@
:root {
color-scheme: dark;
--bg: #090611;
--bg-2: #140d21;
--panel: rgba(25, 18, 41, 0.82);
--panel-strong: rgba(18, 13, 31, 0.92);
--panel-2: rgba(157, 123, 255, 0.12);
--line: rgba(255, 255, 255, 0.08);
--line-strong: rgba(157, 123, 255, 0.26);
--text: #f6f2ff;
--muted: #b8b0cf;
--accent: #9d7bff;
--accent-strong: #7b5cff;
--accent-soft: rgba(157, 123, 255, 0.14);
--good: #95e4ba;
--bad: #ff90a4;
--focus: #b296ff;
--shadow: 0 24px 60px rgba(5, 2, 15, 0.42);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(157, 123, 255, 0.18), transparent 28%),
radial-gradient(circle at top right, rgba(117, 201, 255, 0.12), transparent 24%),
linear-gradient(180deg, #120c1d 0%, #0b0813 56%, #07050d 100%);
color: var(--text);
font: 15px/1.5 "Segoe UI", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 24%);
opacity: 0.5;
}
button,
input,
textarea {
font: inherit;
}
button {
min-height: 40px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--panel-2);
color: var(--text);
padding: 0 14px;
cursor: pointer;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
}
button:hover {
border-color: var(--line-strong);
background: rgba(157, 123, 255, 0.18);
transform: translateY(-1px);
}
button:disabled {
cursor: wait;
opacity: 0.72;
transform: none;
}
input,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(10, 7, 18, 0.82);
color: var(--text);
padding: 0.75rem 0.85rem;
outline: none;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
input {
min-height: 42px;
}
textarea {
resize: vertical;
}
input:focus,
textarea:focus {
border-color: var(--focus);
box-shadow: 0 0 0 3px rgba(178, 150, 255, 0.12);
}
h1,
h2,
h3,
p {
margin: 0;
}
h1 {
font-size: 29px;
line-height: 1.05;
letter-spacing: 0;
}
h2 {
font-size: 17px;
letter-spacing: 0;
}
h3 {
font-size: 15px;
}
.app {
position: relative;
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
min-height: 100vh;
}
.sidebar,
.layout {
padding: 22px;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
overflow: auto;
border-right: 1px solid var(--line);
background: linear-gradient(180deg, rgba(25, 18, 41, 0.94), rgba(14, 10, 24, 0.9));
display: grid;
align-content: start;
gap: 20px;
backdrop-filter: blur(18px) saturate(170%);
}
.layout {
display: grid;
gap: 20px;
min-width: 0;
align-content: start;
}
.topline,
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.brand-wrap {
display: grid;
gap: 6px;
}
.shell-note,
.muted,
.title-button__meta,
.episode span,
.summary p,
label span {
color: var(--muted);
font-size: 13px;
}
.badge {
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 10px;
color: var(--muted);
background: rgba(255, 255, 255, 0.04);
font-size: 12px;
line-height: 1;
white-space: nowrap;
}
.page-links {
display: grid;
gap: 8px;
padding: 6px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
}
.page-link,
.page-action {
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
min-height: 40px;
border: 0;
border-radius: 12px;
padding: 0 14px;
color: var(--muted);
background: transparent;
font-weight: 700;
text-decoration: none;
transition: background 0.16s ease, color 0.16s ease, transform 0.16s ease;
}
.page-link.active,
.page-action:hover {
background: var(--accent-soft);
color: var(--text);
box-shadow: inset 0 0 0 1px rgba(157, 123, 255, 0.22);
}
.page-link:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.05);
}
.search-label,
label {
display: grid;
gap: 7px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.title-list {
display: grid;
gap: 8px;
min-width: 0;
}
.title-button {
display: grid;
gap: 0.2rem;
width: 100%;
min-height: 62px;
border-radius: 16px;
text-align: left;
background: rgba(255, 255, 255, 0.03);
}
.title-button.is-active {
border-color: rgba(157, 123, 255, 0.34);
background: var(--accent-soft);
box-shadow: inset 0 0 0 1px rgba(157, 123, 255, 0.16);
}
.title-button__name {
font-weight: 800;
overflow-wrap: anywhere;
}
.toolbar,
.details,
.empty,
.episode {
border: 1px solid var(--line);
border-radius: 22px;
background: linear-gradient(180deg, rgba(29, 22, 47, 0.88), rgba(17, 13, 29, 0.94));
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.toolbar {
min-height: 76px;
padding: 18px;
}
.details {
padding: 18px;
overflow: hidden;
}
.empty {
min-height: 55vh;
display: grid;
place-content: center;
text-align: center;
gap: 0.6rem;
color: var(--muted);
box-shadow: none;
}
.empty h2 {
color: var(--text);
}
.hero {
display: grid;
grid-template-columns: minmax(190px, 260px) minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.poster {
aspect-ratio: 2 / 3;
border: 1px solid var(--line);
border-radius: 20px;
background:
radial-gradient(circle at 35% 20%, rgba(157, 123, 255, 0.28), transparent 34%),
linear-gradient(180deg, rgba(32, 24, 54, 0.9), rgba(17, 13, 29, 0.96));
overflow: hidden;
display: grid;
place-items: center;
box-shadow: 0 16px 36px rgba(5, 2, 15, 0.3);
}
.poster img {
width: 100%;
height: 100%;
object-fit: cover;
}
.poster span {
color: var(--accent);
font-size: clamp(2.5rem, 7vw, 5rem);
font-weight: 900;
}
.summary {
min-width: 0;
}
.summary__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.summary h2 {
font-size: clamp(1.8rem, 4vw, 3.4rem);
line-height: 1.02;
overflow-wrap: anywhere;
}
.summary p {
margin-top: 0.45rem;
}
.meta-form {
display: grid;
gap: 0.85rem;
max-width: 780px;
}
.meta-form button {
justify-self: start;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
border-color: transparent;
color: #fcfaff;
font-weight: 800;
box-shadow: 0 16px 32px rgba(123, 92, 255, 0.26);
}
.seasons {
display: grid;
gap: 1rem;
margin-top: 1.5rem;
}
.season {
border-top: 1px solid var(--line);
padding-top: 1rem;
}
.season h3 {
color: #ddd0ff;
font-size: 13px;
font-weight: 800;
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.episodes {
display: grid;
gap: 0.9rem;
}
.episode {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(280px, 520px);
gap: 1rem;
align-items: center;
padding: 0.85rem;
box-shadow: none;
}
.episode div {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.episode strong,
.episode span {
overflow-wrap: anywhere;
}
video {
width: 100%;
max-height: 320px;
border: 1px solid var(--line);
border-radius: 16px;
background: #050308;
}
@media (max-width: 980px) {
.app,
.hero,
.episode {
grid-template-columns: 1fr;
}
.sidebar {
position: relative;
height: auto;
max-height: none;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.layout {
padding-top: 18px;
}
.summary__header,
.toolbar {
align-items: flex-start;
flex-direction: column;
}
}
@media (max-width: 560px) {
.sidebar,
.layout {
padding: 16px;
}
.topline {
align-items: flex-start;
}
.details,
.toolbar {
border-radius: 18px;
padding: 14px;
}
.summary h2 {
font-size: 2rem;
}
}
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kaizoku Library</title>
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="topline">
<div class="brand-wrap">
<h1>Kaizoku</h1>
<p class="shell-note">Private anime library</p>
</div>
<span id="libraryBadge" class="badge">Library</span>
</div>
<nav class="page-links" aria-label="Primary navigation">
<a class="page-link active" href="/">Collection</a>
<button id="rescanBtn" class="page-action" type="button">Rescan</button>
</nav>
<label class="search-label">
<span>Search collection</span>
<input id="searchInput" type="search" placeholder="Title, folder, file" autocomplete="off">
</label>
<div id="titleList" class="title-list" aria-label="Titles"></div>
</aside>
<main class="layout">
<header class="toolbar">
<div>
<h2>Collection</h2>
<p id="libraryPath" class="muted"></p>
</div>
<span class="badge">Local</span>
</header>
<section id="details" class="details">
<div class="empty">
<h2>No titles found</h2>
<p>Mount or create a Library folder with one folder per movie or series.</p>
</div>
</section>
</main>
</div>
<template id="titleButtonTemplate">
<button class="title-button" type="button">
<span class="title-button__name"></span>
<span class="title-button__meta"></span>
</button>
</template>
<script src="/assets/app.js"></script>
</body>
</html>