Files
h-play/public/assets/app.js
T

379 lines
12 KiB
JavaScript

const state = {
titles: [],
activeId: "",
query: "",
view: "collection"
};
const els = {
libraryBadge: document.querySelector("#libraryBadge"),
backBtn: document.querySelector("#backBtn"),
rescanBtn: document.querySelector("#rescanBtn"),
searchInput: document.querySelector("#searchInput"),
details: document.querySelector("#details"),
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) {
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.libraryBadge.textContent = `${state.titles.length} title${state.titles.length === 1 ? "" : "s"}`;
if (!state.titles.some((title) => title.id === state.activeId)) {
state.activeId = "";
state.view = "collection";
}
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() {
const activeTitle = state.titles.find((item) => item.id === state.activeId);
els.backBtn.classList.toggle("hidden", state.view !== "details");
if (state.view === "details" && activeTitle) {
renderDetails(activeTitle);
return;
}
renderCollection();
}
function renderCollection() {
const titles = filteredTitles();
if (!titles.length) {
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="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 = `
<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>
<div class="summary__actions">
<button id="editMetaBtn" type="button">Edit</button>
<button id="fetchMetadataBtn" type="button">Metadata</button>
<button id="makeThumbBtn" type="button">Thumbnail</button>
</div>
</div>
<div id="metaView" class="meta-view">
<section>
<span>Description</span>
<p>${title.description ? escapeHtml(title.description) : "No description yet"}</p>
</section>
</div>
<div id="metaEditMount"></div>
</div>
</div>
<div class="episode-toolbar">
<button id="playAllBtn" class="primary" type="button">Play all</button>
</div>
<div class="seasons">
${title.seasons.map(renderSeason).join("")}
</div>
`;
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";
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";
}
});
document.querySelector("#fetchMetadataBtn").addEventListener("click", async (event) => {
event.currentTarget.disabled = true;
event.currentTarget.textContent = "Fetching";
try {
await api(`/api/titles/${encodeURIComponent(title.id)}/metadata`, { method: "POST", body: "{}" });
await loadLibrary();
} catch (err) {
alert(err.message);
event.currentTarget.disabled = false;
event.currentTarget.textContent = "Metadata";
}
});
}
function renderMetadataForm(title) {
document.querySelector("#metaView").classList.add("hidden");
const mount = document.querySelector("#metaEditMount");
mount.innerHTML = `
<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>
<div class="meta-form__actions">
<button class="primary" type="submit">Save metadata</button>
<button id="cancelMetaBtn" class="ghost" type="button">Cancel</button>
</div>
</form>
`;
document.querySelector("#cancelMetaBtn").addEventListener("click", () => {
mount.innerHTML = "";
document.querySelector("#metaView").classList.remove("hidden");
});
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();
});
}
function renderSeason(season) {
return `
<section class="season">
<h3>${escapeHtml(season.name)}</h3>
<div class="episodes">
${season.videos.map((video) => `
<button class="episode" type="button" data-video-id="${escapeAttr(video.id)}">
<span class="episode__thumb">
<img
src="${escapeAttr(video.thumbnail)}"
data-still="${escapeAttr(video.thumbnail)}"
data-animated="${escapeAttr(video.animatedThumbnail)}"
alt=""
loading="lazy"
onerror="this.hidden=true"
>
<span class="episode__play">Play</span>
</span>
<span class="episode__name">${escapeHtml(video.name)}</span>
<span class="episode__meta">${escapeHtml(video.relativePath)} · ${bytes(video.size)}</span>
</button>
`).join("")}
</div>
</section>
`;
}
function flattenVideos(title) {
return title.seasons.flatMap((season) => season.videos);
}
function wireEpisodeCards(title) {
const videos = flattenVideos(title);
document.querySelectorAll("[data-video-id]").forEach((node) => {
const image = node.querySelector("img");
const showAnimated = () => {
if (image?.dataset.animated && image.src !== new URL(image.dataset.animated, window.location.href).href) {
image.hidden = false;
image.src = image.dataset.animated;
}
};
const showStill = () => {
if (image?.dataset.still && image.src !== new URL(image.dataset.still, window.location.href).href) {
image.hidden = false;
image.src = image.dataset.still;
}
};
node.addEventListener("mouseenter", showAnimated);
node.addEventListener("focus", showAnimated);
node.addEventListener("mouseleave", showStill);
node.addEventListener("blur", showStill);
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();
}
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;
state.view = "collection";
render();
});
els.backBtn.addEventListener("click", () => {
state.view = "collection";
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";
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>`;
});