mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
playlists(#37): "Add to playlist" create/edit flow + enable FEATURE_PLAYLISTS
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 <[email protected]>
This commit is contained in:
1 parent
291d55d966
commit
f4fe4982bd
4 files changed
+292
-1
No files matched your search
+1
-1
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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;
|
||||
Reference in new issue
Block a user