From f4fe4982bd0b8aaf1d3c48144775cb501943c626 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:57:09 -0400 Subject: [PATCH] playlists(#37): "Add to playlist" create/edit flow + enable FEATURE_PLAYLISTS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create path — makes playlists actually usable end-to-end. - js/ui/playlistPicker.js: an "Add to playlist" dialog (modeled on confirmDialog) listing the signed-in user's playlists as checkboxes (checked when the video is already in them) + a "Create new playlist" input. On Save it diffs the checkboxes and publishes only changed playlists (add/remove the video) plus any new one, via the facade (active signer). Reads the current pubkey from nostrClient, so callers pass only the video coordinate. - videoMenuRenderers.js: "Add to playlist" item in the video ⋯ menu, gated by a new canManagePlaylists param. - moreMenuController.js: computes canManagePlaylists (FEATURE_PLAYLISTS && logged in), threads it into the panel context, and adds the add-to-playlist handler (resolves the video → coordinate via buildVideoAddressPointer, closes the popover, opens the picker; success/error routed to the app's toasts). - constants.js: FEATURE_PLAYLISTS flipped ON now that the core loop (create / add / remove / channel section / playlist view) works. Rollback: set false. Owner controls on the playlist view (rename / drag-reorder / hard-delete) are a follow-up; removing every video empties a playlist (drops from the listing). Syntax + lint + build clean; more-menu-controller tests 9/9. Co-Authored-By: Claude Opus 4.8 --- js/constants.js | 2 +- js/ui/components/videoMenuRenderers.js | 9 + js/ui/moreMenuController.js | 41 +++++ js/ui/playlistPicker.js | 241 +++++++++++++++++++++++++ 4 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 js/ui/playlistPicker.js diff --git a/js/constants.js b/js/constants.js index 1337bf98..53360d9b 100644 --- a/js/constants.js +++ b/js/constants.js @@ -201,7 +201,7 @@ const DEFAULT_FLAGS = Object.freeze({ FEATURE_TRENDING_FEED: true, // "Trending" tab: recently-added ranked by view count. Disable to hide the tab. FEATURE_MOST_ZAPPED_FEED: true, // "Most Zapped" tab: recently-added ranked by zap total. Disable to hide the tab. FEATURE_ZAP_TALLY: true, // bitvid-native preimage-verified zap tally (publish + count). Rollback: set false. - FEATURE_PLAYLISTS: false, // Creator playlists (#37): channel section + playlist view + add-to-playlist. Default OFF until the UI is complete. + FEATURE_PLAYLISTS: true, // Creator playlists (#37): channel section + playlist view + add-to-playlist (⋯ menu). Rollback: set false to hide all playlist UI. TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, WSS_TRACKERS: DEFAULT_WSS_TRACKERS, diff --git a/js/ui/components/videoMenuRenderers.js b/js/ui/components/videoMenuRenderers.js index 332eeae7..de8ad457 100644 --- a/js/ui/components/videoMenuRenderers.js +++ b/js/ui/components/videoMenuRenderers.js @@ -159,6 +159,7 @@ export function createVideoMoreMenuPanel({ playbackUrl = "", playbackMagnet = "", canManageBlacklist = false, + canManagePlaylists = false, context = "card", designSystem = null, } = {}) { @@ -193,6 +194,14 @@ export function createVideoMoreMenuPanel({ dataset: { eventId: metadata.id, context }, }); + if (canManagePlaylists) { + appendMenuAction(doc, list, { + text: "Add to playlist", + action: "add-to-playlist", + dataset: { eventId: metadata.id, context }, + }); + } + const baseBoostDataset = { eventId: metadata.id, author: metadata.pubkey, diff --git a/js/ui/moreMenuController.js b/js/ui/moreMenuController.js index 08b905b1..4dc5a6aa 100644 --- a/js/ui/moreMenuController.js +++ b/js/ui/moreMenuController.js @@ -6,6 +6,9 @@ import { } from "./components/videoMenuRenderers.js"; import createPopover from "./overlay/popoverEngine.js"; import { handleBlacklistEventAction } from "./moreMenu/blacklistEventAction.js"; +import { openPlaylistPicker } from "./playlistPicker.js"; +import { buildVideoAddressPointer } from "../utils/videoPointer.js"; +import { FEATURE_PLAYLISTS } from "../constants.js"; import { prepareStaticModal, openStaticModal, @@ -731,6 +734,7 @@ export default class MoreMenuController { playbackUrl: entry.context.playbackUrl, playbackMagnet: entry.context.playbackMagnet, canManageBlacklist: entry.context.canManageBlacklist, + canManagePlaylists: entry.context.canManagePlaylists, context: entry.context.context || "card", designSystem: entry.context.designSystem || this.designSystem, }); @@ -906,6 +910,8 @@ export default class MoreMenuController { playbackMagnet = "", context = "card", canManageBlacklist = this.callbacks.canCurrentUserManageBlacklist(), + canManagePlaylists = FEATURE_PLAYLISTS && + !!this.callbacks.getCurrentUserPubkey?.(), designSystem = null, onAction = null, onClose = null, @@ -924,6 +930,7 @@ export default class MoreMenuController { playbackUrl, playbackMagnet, canManageBlacklist, + canManagePlaylists, context, designSystem: designSystem || this.designSystem, onAction, @@ -1199,6 +1206,40 @@ export default class MoreMenuController { } break; } + case "add-to-playlist": { + let targetVideo = video || null; + if (!targetVideo && dataset?.eventId) { + targetVideo = + this.callbacks.getVideoByEventId(dataset.eventId) || null; + } + if (!targetVideo && context === "modal") { + targetVideo = currentVideo || null; + } + if (!targetVideo) { + this.callbacks.showError("No video selected."); + break; + } + const coordinate = buildVideoAddressPointer(targetVideo); + if (!coordinate) { + this.callbacks.showError("Couldn’t identify this video."); + break; + } + try { + this.activePopover?.close?.(); + } catch (error) { + // ignore + } + openPlaylistPicker({ + videoCoordinate: coordinate, + notify: (kind, message) => + kind === "success" + ? this.callbacks.showSuccess(message) + : this.callbacks.showError(message), + }).catch((error) => { + userLogger.warn("[MoreMenu] Add-to-playlist failed:", error); + }); + break; + } case "remove-history": { await this.callbacks.handleRemoveHistoryAction(dataset); break; diff --git a/js/ui/playlistPicker.js b/js/ui/playlistPicker.js new file mode 100644 index 00000000..8de5c4c1 --- /dev/null +++ b/js/ui/playlistPicker.js @@ -0,0 +1,241 @@ +// js/ui/playlistPicker.js +// +// "Add to playlist" dialog (#37). Modeled on confirmDialog.js — builds its own +// `bv-modal modal-always-on-top` overlay so it stacks over the video modal. Lists +// the signed-in user's playlists as checkboxes (checked when the video is already +// in them) plus a "Create new playlist" input. On Save it diffs the checkboxes, +// adds/removes the video in each changed playlist, creates a new one if named, +// and publishes each change via the facade (active signer). Self-contained: it +// reads the current pubkey from nostrClient, so callers only pass the video's +// addressable coordinate. + +import { nostrClient } from "../nostrClientFacade.js"; +import { + fetchCreatorPlaylists, + publishPlaylist, +} from "../playlists/playlistFacade.js"; +import { + addVideoToPlaylist, + removeVideoFromPlaylist, + generatePlaylistId, + playlistItemKey, +} from "../playlists/playlistService.js"; +import { userLogger } from "../utils/logger.js"; + +function hasVideo(playlist, coordinate) { + const key = `a:${coordinate}`; + return playlist.items.some((item) => playlistItemKey(item) === key); +} + +function makeButton(label, { ghost = false } = {}) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = ghost ? "btn-ghost focus-ring" : "btn focus-ring"; + btn.textContent = label; + return btn; +} + +/** + * Open the "Add to playlist" dialog for a video. + * @param {Object} params + * @param {string} params.videoCoordinate "30078:pubkey:d" for the video + * @param {(kind: "success"|"error", message: string) => void} [params.notify] + * @returns {Promise} + */ +export async function openPlaylistPicker({ videoCoordinate, notify } = {}) { + const say = typeof notify === "function" ? notify : () => {}; + const coordinate = + typeof videoCoordinate === "string" ? videoCoordinate.trim() : ""; + if (!coordinate) { + say("error", "Couldn't identify this video."); + return; + } + + const pubkey = + typeof nostrClient?.pubkey === "string" + ? nostrClient.pubkey.trim().toLowerCase() + : ""; + if (!pubkey) { + say("error", "Log in to add videos to a playlist."); + return; + } + + if (typeof document === "undefined" || !document.body) { + return; + } + + let playlists = []; + try { + playlists = await fetchCreatorPlaylists(pubkey, { includeEmpty: true }); + } catch (error) { + userLogger.warn("[playlistPicker] Failed to load playlists:", error); + playlists = []; + } + + await new Promise((resolve) => { + const overlay = document.createElement("div"); + overlay.className = + "bv-modal modal-always-on-top items-start justify-center md:items-center"; + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + overlay.setAttribute("aria-label", "Add to playlist"); + + const backdrop = document.createElement("div"); + backdrop.className = "bv-modal-backdrop"; + overlay.appendChild(backdrop); + + const sheet = document.createElement("div"); + sheet.className = "modal-sheet w-full max-w-md flex flex-col"; + sheet.tabIndex = -1; + overlay.appendChild(sheet); + + const header = document.createElement("div"); + header.className = "modal-header"; + const heading = document.createElement("h2"); + heading.className = "text-lg font-bold text-text"; + heading.textContent = "Add to playlist"; + header.appendChild(heading); + sheet.appendChild(header); + + const body = document.createElement("div"); + body.className = "p-5 flex flex-col gap-3 max-h-[60vh] overflow-y-auto"; + sheet.appendChild(body); + + // Existing playlists as checkboxes. + const checkboxes = []; // { playlist, input, wasIn } + for (const playlist of playlists) { + const row = document.createElement("label"); + row.className = "flex items-center gap-3 cursor-pointer"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.className = "shrink-0"; + const wasIn = hasVideo(playlist, coordinate); + input.checked = wasIn; + const text = document.createElement("span"); + text.className = "text-sm text-text"; + const n = playlist.items.length; + text.textContent = `${playlist.title} (${n})`; + row.appendChild(input); + row.appendChild(text); + body.appendChild(row); + checkboxes.push({ playlist, input, wasIn }); + } + + if (!playlists.length) { + const empty = document.createElement("p"); + empty.className = "text-sm text-muted"; + empty.textContent = "You don't have any playlists yet — create one below."; + body.appendChild(empty); + } + + // Create-new field. + const newWrap = document.createElement("div"); + newWrap.className = "flex flex-col gap-1 border-t border-border/60 pt-3"; + const newLabel = document.createElement("label"); + newLabel.className = "text-xs font-medium text-muted"; + newLabel.textContent = "Create new playlist"; + const newInput = document.createElement("input"); + newInput.type = "text"; + newInput.placeholder = "Playlist name"; + newInput.className = "form-input"; + newInput.maxLength = 120; + newWrap.appendChild(newLabel); + newWrap.appendChild(newInput); + body.appendChild(newWrap); + + const footer = document.createElement("div"); + footer.className = + "flex items-center justify-end gap-3 border-t border-border/60 p-4"; + const cancelBtn = makeButton("Cancel", { ghost: true }); + const saveBtn = makeButton("Save"); + footer.appendChild(cancelBtn); + footer.appendChild(saveBtn); + sheet.appendChild(footer); + + let settled = false; + const cleanup = () => { + if (settled) { + return; + } + settled = true; + document.removeEventListener("keydown", onKeydown, true); + overlay.remove(); + resolve(); + }; + function onKeydown(event) { + if (event.key === "Escape") { + event.preventDefault(); + cleanup(); + } + } + + async function onSave() { + saveBtn.disabled = true; + cancelBtn.disabled = true; + saveBtn.textContent = "Saving…"; + + const jobs = []; + // Diff existing playlists: only publish those whose membership changed. + for (const { playlist, input, wasIn } of checkboxes) { + if (input.checked === wasIn) { + continue; + } + const items = input.checked + ? addVideoToPlaylist(playlist.items, coordinate) + : removeVideoFromPlaylist(playlist.items, coordinate); + jobs.push({ ...playlist, items }); + } + // New playlist, if named. + const newName = newInput.value.trim(); + if (newName) { + jobs.push({ + pubkey, + id: generatePlaylistId(), + title: newName, + items: [{ type: "a", value: coordinate }], + }); + } + + if (!jobs.length) { + cleanup(); + return; + } + + let ok = 0; + let failed = 0; + for (const job of jobs) { + try { + await publishPlaylist({ pubkey, ...job }); + ok += 1; + } catch (error) { + userLogger.warn("[playlistPicker] Failed to publish playlist:", error); + failed += 1; + } + } + + cleanup(); + if (ok && !failed) { + say( + "success", + ok === 1 ? "Playlist updated." : `Updated ${ok} playlists.`, + ); + } else if (ok && failed) { + say("error", `Updated ${ok}; ${failed} could not be saved.`); + } else { + say("error", "Couldn't save your playlist changes."); + } + } + + cancelBtn.addEventListener("click", cleanup); + backdrop.addEventListener("click", cleanup); + saveBtn.addEventListener("click", () => { + onSave().catch(() => cleanup()); + }); + document.addEventListener("keydown", onKeydown, true); + + document.body.appendChild(overlay); + (playlists.length ? saveBtn : newInput).focus(); + }); +} + +export default openPlaylistPicker;