// js/ui/profileModal/ProfilePlaylistsController.js // // The profile modal's "Playlists" pane (#37) — a central place to manage your // playlists: rename, delete (NIP-09), and expand one to reorder (↑/↓) or remove // its videos. Mirrors MyVideosController's shape (constructor(mainController) + // cacheDomReferences/registerEventListeners + a populate/refresh that runs when // the pane is selected). All writes go through the playlist facade (active // signer); edits are optimistic and re-sync on failure. Gated by FEATURE_PLAYLISTS. import { nostrClient } from "../../nostrClientFacade.js"; import { fetchCreatorPlaylists, publishPlaylist, deletePlaylist, } from "../../playlists/playlistFacade.js"; import { reorderPlaylistItems, removeVideoFromPlaylist, } from "../../playlists/playlistService.js"; import { buildVideoAddressPointer } from "../../utils/videoPointer.js"; import { showConfirm } from "../confirmDialog.js"; import { showTextPrompt } from "../promptDialog.js"; import { FEATURE_PLAYLISTS } from "../../constants.js"; import { devLogger } from "../../utils/logger.js"; function el(tag, className, text) { const node = document.createElement(tag); if (className) { node.className = className; } if (text != null) { node.textContent = text; } return node; } function iconButton(label, action, dataset, svgPath) { const btn = el("button", "btn-ghost btn-icon"); btn.type = "button"; btn.dataset.size = "sm"; btn.setAttribute("aria-label", label); btn.dataset.plAction = action; Object.entries(dataset || {}).forEach(([k, v]) => { btn.dataset[k] = String(v); }); const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "2"); svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round"); svg.setAttribute("class", "h-4 w-4"); svg.setAttribute("aria-hidden", "true"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", svgPath); svg.appendChild(path); btn.appendChild(svg); return btn; } const ICONS = { up: "M12 19V5M5 12l7-7 7 7", down: "M12 5v14M5 12l7 7 7-7", remove: "M6 6l12 12M18 6L6 18", rename: "M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z", trash: "M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6", chevron: "M9 18l6-6-6-6", }; export class ProfilePlaylistsController { constructor(mainController) { this.mainController = mainController; this.listEl = null; this.loadingEl = null; this.emptyEl = null; this.refreshBtn = null; this.pubkey = ""; this.playlists = []; this.expanded = new Set(); this.loading = false; this.refreshRequestId = 0; this.fetchCreatorPlaylists = fetchCreatorPlaylists; this._drag = null; this._dropTarget = null; this._onChanged = () => { void this.refresh(); }; } cacheDomReferences() { this.listEl = document.getElementById("profilePlaylistsList") || null; this.loadingEl = document.getElementById("profilePlaylistsLoading") || null; this.emptyEl = document.getElementById("profilePlaylistsEmpty") || null; this.refreshBtn = document.getElementById("profilePlaylistsRefreshBtn") || null; } registerEventListeners() { if (this.refreshBtn instanceof HTMLElement) { this.refreshBtn.addEventListener("click", () => { void this.refresh(); }); } if (this.listEl instanceof HTMLElement) { this.listEl.addEventListener("click", (event) => this.handleListClick(event), ); // Desktop drag-to-reorder for videos within a playlist. The ↑/↓ buttons // stay as the accessible / touch-friendly path. this.listEl.addEventListener("dragstart", (event) => this.handleDragStart(event), ); this.listEl.addEventListener("dragover", (event) => this.handleDragOver(event), ); this.listEl.addEventListener("drop", (event) => this.handleDrop(event)); this.listEl.addEventListener("dragend", () => this.clearDrag()); } if (typeof document !== "undefined") { document.addEventListener("bitvid:playlists-changed", this._onChanged); } } setLoading(value) { this.loading = value; if (this.loadingEl) { this.loadingEl.classList.toggle("hidden", !value); } } async refresh() { if (!FEATURE_PLAYLISTS || !this.listEl) { return; } const active = this.mainController.getActivePubkey?.() || ""; this.pubkey = typeof active === "string" ? active.trim().toLowerCase() : ""; if (!this.pubkey) { this.playlists = []; this.render(); return; } const requestId = ++this.refreshRequestId; this.setLoading(true); let playlists = []; try { playlists = await this.fetchCreatorPlaylists(this.pubkey, { includeEmpty: true, }); } catch (error) { devLogger.warn("[playlists] Failed to load playlists for pane:", error); } this.playlists = playlists; this.setLoading(false); // Show the playlist structure as soon as the list itself is available. // Resolving every referenced video only improves labels/thumbnails and // must not keep the management pane blank on a cold cache. this.render(); // Fetch the referenced creators' videos so the expanded sublists can show // real titles/thumbnails (cache may be cold in the modal). const authors = new Set(); for (const playlist of playlists) { for (const item of playlist.items) { if (item.type === "a") { const author = item.value.split(":")[1]; if (author) { authors.add(author); } } } } const nostrService = this.mainController.services?.nostrService; if (authors.size && typeof nostrService?.fetchVideosByAuthors === "function") { void nostrService.fetchVideosByAuthors([...authors]) .catch(() => { // Best effort — keep the coordinate labels if metadata is unavailable. }) .then(() => { if (this.refreshRequestId === requestId) { this.render(); } }); } } // coord -> { title, thumbnail } from the app's active videos. buildVideoMap() { const map = new Map(); let active = []; try { active = nostrClient.getActiveVideos() || []; } catch (error) { active = []; } for (const video of active) { const coordinate = buildVideoAddressPointer(video); if (coordinate && !map.has(coordinate)) { map.set(coordinate, { title: typeof video.title === "string" ? video.title : "", thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "", }); } } return map; } render() { if (!this.listEl) { return; } this.listEl.replaceChildren(); if (!this.playlists.length) { if (this.emptyEl) { this.emptyEl.classList.remove("hidden"); } return; } if (this.emptyEl) { this.emptyEl.classList.add("hidden"); } const videoMap = this.buildVideoMap(); for (const playlist of this.playlists) { this.listEl.appendChild(this.renderRow(playlist, videoMap)); } } renderRow(playlist, videoMap) { const li = el("li", "playlist-row"); const head = el("div", "playlist-row__head"); head.dataset.plAction = "toggle"; head.dataset.plId = playlist.id; const chevron = el("span", "playlist-row__chevron"); if (this.expanded.has(playlist.id)) { chevron.dataset.open = "true"; } const chevSvg = document.createElementNS( "http://www.w3.org/2000/svg", "svg", ); chevSvg.setAttribute("viewBox", "0 0 24 24"); chevSvg.setAttribute("fill", "none"); chevSvg.setAttribute("stroke", "currentColor"); chevSvg.setAttribute("stroke-width", "2"); chevSvg.setAttribute("class", "h-4 w-4"); chevSvg.setAttribute("aria-hidden", "true"); const chevPath = document.createElementNS( "http://www.w3.org/2000/svg", "path", ); chevPath.setAttribute("d", ICONS.chevron); chevSvg.appendChild(chevPath); chevron.appendChild(chevSvg); head.appendChild(chevron); const meta = el("div", "playlist-row__meta"); meta.appendChild(el("p", "playlist-row__title", playlist.title)); const n = playlist.items.length; meta.appendChild( el("p", "playlist-row__count", `${n} ${n === 1 ? "video" : "videos"}`), ); head.appendChild(meta); const actions = el("div", "playlist-row__actions"); actions.appendChild( iconButton("Rename playlist", "rename", { plId: playlist.id }, ICONS.rename), ); actions.appendChild( iconButton("Delete playlist", "delete", { plId: playlist.id }, ICONS.trash), ); head.appendChild(actions); li.appendChild(head); if (this.expanded.has(playlist.id)) { li.appendChild(this.renderVideoList(playlist, videoMap)); } return li; } renderVideoList(playlist, videoMap) { const wrap = el("ul", "playlist-row__videos"); if (!playlist.items.length) { wrap.appendChild(el("li", "playlist-row__empty", "This playlist is empty.")); return wrap; } playlist.items.forEach((item, index) => { const row = el("li", "playlist-video"); row.draggable = true; row.dataset.plId = playlist.id; row.dataset.plIndex = String(index); const info = videoMap.get(item.value); const dtag = item.value.split(":")[2] || item.value; const label = info?.title || dtag; const title = el("span", "playlist-video__title", label); row.appendChild(title); const controls = el("div", "playlist-video__controls"); const up = iconButton( "Move up", "move-up", { plId: playlist.id, plIndex: index }, ICONS.up, ); if (index === 0) { up.disabled = true; } const down = iconButton( "Move down", "move-down", { plId: playlist.id, plIndex: index }, ICONS.down, ); if (index === playlist.items.length - 1) { down.disabled = true; } const remove = iconButton( "Remove from playlist", "remove", { plId: playlist.id, plCoord: item.value }, ICONS.remove, ); controls.appendChild(up); controls.appendChild(down); controls.appendChild(remove); row.appendChild(controls); wrap.appendChild(row); }); return wrap; } handleListClick(event) { const target = event.target instanceof Element ? event.target.closest("[data-pl-action]") : null; if (!target) { return; } const action = target.dataset.plAction; const id = target.dataset.plId; const playlist = this.playlists.find((p) => p.id === id); if (!playlist) { return; } if (action === "toggle") { this.toggleExpand(id); } else if (action === "rename") { void this.rename(playlist); } else if (action === "delete") { void this.deletePlaylistRow(playlist); } else if (action === "move-up") { void this.moveVideo(playlist, Number(target.dataset.plIndex), -1); } else if (action === "move-down") { void this.moveVideo(playlist, Number(target.dataset.plIndex), 1); } else if (action === "remove") { void this.removeVideo(playlist, target.dataset.plCoord); } } handleDragStart(event) { const row = event.target?.closest?.(".playlist-video"); if (!row) { return; } this._drag = { playlistId: row.dataset.plId, fromIndex: Number(row.dataset.plIndex), }; if (event.dataTransfer) { event.dataTransfer.effectAllowed = "move"; try { event.dataTransfer.setData("text/plain", String(this._drag.fromIndex)); } catch (error) { // some browsers require setData(); ignore failures } } row.classList.add("playlist-video--dragging"); } handleDragOver(event) { if (!this._drag) { return; } const row = event.target?.closest?.(".playlist-video"); if (!row || row.dataset.plId !== this._drag.playlistId) { return; } event.preventDefault(); if (event.dataTransfer) { event.dataTransfer.dropEffect = "move"; } if (this._dropTarget && this._dropTarget !== row) { this._dropTarget.classList.remove("playlist-video--drop-target"); } if (Number(row.dataset.plIndex) !== this._drag.fromIndex) { row.classList.add("playlist-video--drop-target"); this._dropTarget = row; } } handleDrop(event) { const drag = this._drag; const row = event.target?.closest?.(".playlist-video"); this.clearDrag(); if (!drag || !row || row.dataset.plId !== drag.playlistId) { return; } event.preventDefault(); const toIndex = Number(row.dataset.plIndex); if ( !Number.isInteger(drag.fromIndex) || !Number.isInteger(toIndex) || drag.fromIndex === toIndex ) { return; } const playlist = this.playlists.find((p) => p.id === drag.playlistId); if (!playlist) { return; } const items = reorderPlaylistItems(playlist.items, drag.fromIndex, toIndex); void this.publishUpdate({ ...playlist, items }, null); } clearDrag() { this._drag = null; this._dropTarget = null; if (this.listEl) { this.listEl .querySelectorAll( ".playlist-video--dragging, .playlist-video--drop-target", ) .forEach((node) => { node.classList.remove( "playlist-video--dragging", "playlist-video--drop-target", ); }); } } toggleExpand(id) { if (this.expanded.has(id)) { this.expanded.delete(id); } else { this.expanded.add(id); } this.render(); } async rename(playlist) { const name = await showTextPrompt("Give this playlist a new name.", { title: "Rename playlist", value: playlist.title, confirmLabel: "Save", placeholder: "Playlist name", }); if (!name || name === playlist.title) { return; } await this.publishUpdate({ ...playlist, title: name }, "Playlist renamed."); } async deletePlaylistRow(playlist) { const ok = await showConfirm( `Delete “${playlist.title}”? This removes the playlist for everyone. It can’t be undone.`, { title: "Delete playlist", confirmLabel: "Delete", danger: true }, ); if (!ok) { return; } this.playlists = this.playlists.filter((p) => p.id !== playlist.id); this.expanded.delete(playlist.id); this.render(); try { await deletePlaylist(playlist); this.mainController.showSuccess?.("Playlist deleted."); this.emitChanged(); } catch (error) { devLogger.warn("[playlists] Delete failed:", error); this.mainController.showError?.("Couldn’t delete the playlist."); void this.refresh(); } } async moveVideo(playlist, index, direction) { if (!Number.isInteger(index)) { return; } const items = reorderPlaylistItems(playlist.items, index, index + direction); await this.publishUpdate({ ...playlist, items }, null); } async removeVideo(playlist, coordinate) { const items = removeVideoFromPlaylist(playlist.items, coordinate); await this.publishUpdate({ ...playlist, items }, "Removed from playlist."); } async publishUpdate(updated, successMessage) { const index = this.playlists.findIndex((p) => p.id === updated.id); if (index >= 0) { this.playlists[index] = { ...this.playlists[index], ...updated }; } this.render(); try { await publishPlaylist({ pubkey: this.pubkey, ...updated }); if (successMessage) { this.mainController.showSuccess?.(successMessage); } this.emitChanged(); } catch (error) { devLogger.warn("[playlists] Update failed:", error); this.mainController.showError?.("Couldn’t save the change."); void this.refresh(); } } emitChanged() { try { document.dispatchEvent( new CustomEvent("bitvid:playlists-changed", { detail: { pubkey: this.pubkey }, }), ); } catch (error) { // best effort } } } export default ProfilePlaylistsController;