mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Design polish from feedback: - Playlist cards are now a "stack of clips" cover: the first video's thumbnail fills a 16:9 tile with two offset/rotated layers peeking behind it (::before/ ::after). A themed gradient scrim (accent-strong→accent→clear, via color-mix) darkens the base so the title sits BOLD and WHITE over the cover instead of small grey text underneath. Video count moved to a pill in the top-right. channelPlaylists.js resolves the cover from the app's active videos (buildVideoAddressPointer → video.thumbnail), falling back to playlist.image. - Back button on the playlist view: was a bare "← Back to channel" text link, now a proper .btn-ghost (sm) pill with a chevron icon — matches the site's buttons and is theme-aware. - tokens.css: added --size-4px/--size-5px for the stack offsets (token lint). Scrim/title use --color-white (always light — the scrim is always dark regardless of theme). Lint + build clean. Co-Authored-By: Claude Opus 4.8 <[email protected]>
206 lines
5.7 KiB
JavaScript
206 lines
5.7 KiB
JavaScript
// js/channelPlaylists.js
|
|
//
|
|
// Channel-page "Playlists" section (#37). Fetches the creator's playlists and
|
|
// renders a row of cards linking to the playlist view. Kept out of
|
|
// channelProfile.js (that file is at its size cap) and wired in via a one-line
|
|
// hook, mirroring channelZapTotal.js. Gated by FEATURE_PLAYLISTS — with the flag
|
|
// off it renders nothing and does no fetch.
|
|
//
|
|
// Also listens for `bitvid:playlists-changed` (dispatched by the "Add to
|
|
// playlist" picker) so the section refreshes right after you add/create a
|
|
// playlist, instead of needing a manual page reload.
|
|
|
|
import { fetchCreatorPlaylists } from "./playlists/playlistFacade.js";
|
|
import { nostrClient } from "./nostrClientFacade.js";
|
|
import { buildVideoAddressPointer } from "./utils/videoPointer.js";
|
|
import { FEATURE_PLAYLISTS } from "./constants.js";
|
|
import { devLogger } from "./utils/logger.js";
|
|
|
|
let currentToken = 0;
|
|
let currentHex = "";
|
|
let currentClient;
|
|
let currentDoc = null;
|
|
let listenerAttached = false;
|
|
|
|
function clearChildren(el) {
|
|
while (el.firstChild) {
|
|
el.removeChild(el.firstChild);
|
|
}
|
|
}
|
|
|
|
function playlistHref(playlist) {
|
|
const params = new URLSearchParams({
|
|
view: "playlist",
|
|
pubkey: playlist.pubkey,
|
|
id: playlist.id,
|
|
});
|
|
return `#${params.toString()}`;
|
|
}
|
|
|
|
// coord -> thumbnail URL, from the app's active videos, so a playlist can show
|
|
// its first video's thumbnail as the cover.
|
|
function buildThumbnailMap() {
|
|
const map = new Map();
|
|
let active = [];
|
|
try {
|
|
active = nostrClient.getActiveVideos() || [];
|
|
} catch (error) {
|
|
active = [];
|
|
}
|
|
for (const video of active) {
|
|
const coordinate = buildVideoAddressPointer(video);
|
|
if (coordinate && typeof video.thumbnail === "string" && video.thumbnail) {
|
|
map.set(coordinate, video.thumbnail);
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function coverFor(playlist, thumbnails) {
|
|
for (const item of playlist.items) {
|
|
if (item.type === "a") {
|
|
const thumb = thumbnails.get(item.value);
|
|
if (thumb) {
|
|
return thumb;
|
|
}
|
|
}
|
|
}
|
|
return playlist.image || "";
|
|
}
|
|
|
|
function renderPlaylistCard(doc, playlist, coverUrl) {
|
|
const card = doc.createElement("a");
|
|
card.href = playlistHref(playlist);
|
|
card.className = "playlist-card";
|
|
card.setAttribute(
|
|
"aria-label",
|
|
`Playlist: ${playlist.title} (${playlist.items.length} videos)`,
|
|
);
|
|
|
|
const thumb = doc.createElement("div");
|
|
thumb.className = "playlist-card__thumb";
|
|
|
|
if (coverUrl) {
|
|
const img = doc.createElement("img");
|
|
img.src = coverUrl;
|
|
img.alt = "";
|
|
img.loading = "lazy";
|
|
img.referrerPolicy = "no-referrer";
|
|
img.className = "playlist-card__image";
|
|
thumb.appendChild(img);
|
|
}
|
|
|
|
const scrim = doc.createElement("div");
|
|
scrim.className = "playlist-card__scrim";
|
|
thumb.appendChild(scrim);
|
|
|
|
const count = doc.createElement("span");
|
|
count.className = "playlist-card__count";
|
|
const n = playlist.items.length;
|
|
count.textContent = `${n} ${n === 1 ? "video" : "videos"}`;
|
|
thumb.appendChild(count);
|
|
|
|
const title = doc.createElement("p");
|
|
title.className = "playlist-card__title";
|
|
title.textContent = playlist.title;
|
|
thumb.appendChild(title);
|
|
|
|
card.appendChild(thumb);
|
|
return card;
|
|
}
|
|
|
|
// Fetch + render the currently-wired channel's playlists. Safe to call any time
|
|
// (event refresh, initial wire); a nav token drops a stale fetch.
|
|
async function refresh() {
|
|
const doc = currentDoc || globalThis.document;
|
|
const section =
|
|
doc && typeof doc.getElementById === "function"
|
|
? doc.getElementById("channelPlaylistsSection")
|
|
: null;
|
|
const grid = doc?.getElementById?.("channelPlaylistsGrid") || null;
|
|
if (!section || !grid) {
|
|
return;
|
|
}
|
|
|
|
if (!FEATURE_PLAYLISTS || !currentHex) {
|
|
section.classList.add("hidden");
|
|
return;
|
|
}
|
|
|
|
const token = currentToken;
|
|
let playlists = [];
|
|
try {
|
|
playlists = await fetchCreatorPlaylists(
|
|
currentHex,
|
|
currentClient ? { client: currentClient } : {},
|
|
);
|
|
} catch (error) {
|
|
devLogger.warn("[channelPlaylists] Failed to load playlists:", error);
|
|
playlists = [];
|
|
}
|
|
|
|
// A newer navigation superseded this fetch.
|
|
if (token !== currentToken) {
|
|
return;
|
|
}
|
|
|
|
if (!playlists.length) {
|
|
section.classList.add("hidden");
|
|
return;
|
|
}
|
|
|
|
clearChildren(grid);
|
|
const thumbnails = buildThumbnailMap();
|
|
const frag = doc.createDocumentFragment();
|
|
for (const playlist of playlists) {
|
|
frag.appendChild(
|
|
renderPlaylistCard(doc, playlist, coverFor(playlist, thumbnails)),
|
|
);
|
|
}
|
|
grid.appendChild(frag);
|
|
section.classList.remove("hidden");
|
|
}
|
|
|
|
function onPlaylistsChanged(event) {
|
|
const changed = (event?.detail?.pubkey || "").trim().toLowerCase();
|
|
if (!currentHex || changed !== currentHex) {
|
|
return;
|
|
}
|
|
// Refresh now, then again shortly after — relays need a beat to serve the
|
|
// event we just published back, so the first fetch can miss it.
|
|
refresh();
|
|
setTimeout(() => {
|
|
refresh();
|
|
}, 2000);
|
|
}
|
|
|
|
export function teardownChannelPlaylists() {
|
|
currentToken += 1;
|
|
currentHex = "";
|
|
}
|
|
|
|
/**
|
|
* Populate the channel "Playlists" section for a creator. Hidden when the flag
|
|
* is off, the container is missing, or the creator has no playlists.
|
|
* @param {string} pubkey the channel's hex pubkey
|
|
* @param {{ document?: Document, client?: any }} [opts]
|
|
*/
|
|
export async function wireChannelPlaylists(
|
|
pubkey,
|
|
{ document: doc = globalThis.document, client } = {},
|
|
) {
|
|
teardownChannelPlaylists();
|
|
currentHex = typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
|
|
currentClient = client;
|
|
currentDoc = doc || globalThis.document;
|
|
|
|
if (!listenerAttached && currentDoc?.addEventListener) {
|
|
currentDoc.addEventListener("bitvid:playlists-changed", onPlaylistsChanged);
|
|
listenerAttached = true;
|
|
}
|
|
|
|
await refresh();
|
|
}
|
|
|
|
export default wireChannelPlaylists;
|