379 lines
12 KiB
JavaScript
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/<Movie or Series>/filename.mp4 or Library/<Series>/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) => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'"
|
|
})[char]);
|
|
}
|
|
|
|
function escapeAttr(value) {
|
|
return escapeHtml(value).replace(/`/g, "`");
|
|
}
|
|
|
|
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>`;
|
|
});
|