Add episode thumbnails and playlist player
This commit is contained in:
+83
-10
@@ -7,12 +7,20 @@ const state = {
|
||||
|
||||
const els = {
|
||||
libraryBadge: document.querySelector("#libraryBadge"),
|
||||
libraryPath: document.querySelector("#libraryPath"),
|
||||
backBtn: document.querySelector("#backBtn"),
|
||||
rescanBtn: document.querySelector("#rescanBtn"),
|
||||
searchInput: document.querySelector("#searchInput"),
|
||||
details: document.querySelector("#details"),
|
||||
titleCardTemplate: document.querySelector("#titleCardTemplate")
|
||||
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) {
|
||||
@@ -39,7 +47,6 @@ async function api(path, options = {}) {
|
||||
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.titles.some((title) => title.id === state.activeId)) {
|
||||
state.activeId = "";
|
||||
@@ -123,6 +130,7 @@ function renderDetails(title) {
|
||||
<button id="editMetaBtn" type="button">Edit</button>
|
||||
<button id="fetchMetadataBtn" type="button">Metadata</button>
|
||||
<button id="makeThumbBtn" type="button">Thumbnail</button>
|
||||
<button id="playAllBtn" class="primary" type="button">Play all</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="metaView" class="meta-view">
|
||||
@@ -139,10 +147,16 @@ function renderDetails(title) {
|
||||
</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";
|
||||
@@ -220,19 +234,65 @@ function renderSeason(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>
|
||||
<button class="episode" type="button" data-video-id="${escapeAttr(video.id)}">
|
||||
<span class="episode__thumb">
|
||||
<img src="${escapeAttr(video.thumbnail)}" 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) => {
|
||||
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();
|
||||
}
|
||||
@@ -262,6 +322,19 @@ els.backBtn.addEventListener("click", () => {
|
||||
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";
|
||||
|
||||
+82
-37
@@ -246,28 +246,6 @@ label {
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.sidebar-note {
|
||||
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));
|
||||
@@ -524,41 +502,108 @@ label {
|
||||
|
||||
.episodes {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.episode {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(280px, 520px);
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 0.85rem;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
min-height: auto;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.episode div {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
.episode:hover {
|
||||
border-color: var(--line-strong);
|
||||
background: linear-gradient(180deg, rgba(38, 28, 64, 0.94) 0%, rgba(19, 14, 32, 0.98) 100%);
|
||||
}
|
||||
|
||||
.episode strong,
|
||||
.episode span {
|
||||
.episode__thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
border: 1px solid rgba(157, 123, 255, 0.16);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(157, 123, 255, 0.22), transparent 44%),
|
||||
linear-gradient(180deg, #221734 0%, #0c0915 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.episode__thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.episode__play {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 999px;
|
||||
padding: 5px 9px;
|
||||
color: var(--text);
|
||||
background: rgba(9, 6, 17, 0.74);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.episode__name {
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.episode__meta {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.episode__name,
|
||||
.episode__meta {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
video {
|
||||
.player-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
place-items: center;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
background: #050308;
|
||||
}
|
||||
|
||||
.player-video {
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
max-width: min(100%, 1600px);
|
||||
max-height: calc(100vh - 120px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: #050308;
|
||||
}
|
||||
|
||||
.player-close {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
z-index: 21;
|
||||
}
|
||||
|
||||
.player-now-playing {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app,
|
||||
.hero,
|
||||
.episode {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user