Initial h-play app
This commit is contained in:
@@ -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/<Movie or Series>/filename.mp4 or Library/<Series>/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) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
})[char]);
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replace(/`/g, "`");
|
||||
}
|
||||
|
||||
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>`;
|
||||
});
|
||||
Reference in New Issue
Block a user