mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Fixes two rough edges from first use: - The "Add to playlist" dialog blocked on the relay publish before closing, so it felt stuck / "didn't close, just reset". Now it closes IMMEDIATELY on Save and publishes in the background; the result comes back as a toast (optimistic, like the feed insert). No more waiting on the relay round-trip with the modal open. - After adding/creating a playlist you had to hard-refresh to see it. The picker now dispatches `bitvid:playlists-changed`, and channelPlaylists.js listens for it (matching the current channel) and re-fetches — immediately and again after ~2s to cover relay propagation lag — so the section updates on its own. Syntax + lint + build clean. Co-Authored-By: Claude Opus 4.8 <[email protected]>
255 lines
8.2 KiB
JavaScript
255 lines
8.2 KiB
JavaScript
// 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();
|
|
}
|
|
}
|
|
|
|
function onSave() {
|
|
// Compute the diff synchronously, then close the dialog IMMEDIATELY and
|
|
// publish in the background — relay publishes take a beat, and blocking the
|
|
// dialog on them made it feel stuck / "not close". Success/failure comes
|
|
// back as a toast; a `bitvid:playlists-changed` event lets the channel
|
|
// section refresh without a manual page reload.
|
|
const jobs = [];
|
|
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 });
|
|
}
|
|
const newName = newInput.value.trim();
|
|
if (newName) {
|
|
jobs.push({
|
|
pubkey,
|
|
id: generatePlaylistId(),
|
|
title: newName,
|
|
items: [{ type: "a", value: coordinate }],
|
|
});
|
|
}
|
|
|
|
cleanup();
|
|
if (!jobs.length) {
|
|
return;
|
|
}
|
|
|
|
(async () => {
|
|
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;
|
|
}
|
|
}
|
|
|
|
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.");
|
|
}
|
|
|
|
if (ok) {
|
|
try {
|
|
document.dispatchEvent(
|
|
new CustomEvent("bitvid:playlists-changed", {
|
|
detail: { pubkey },
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
// best effort
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
|
|
cancelBtn.addEventListener("click", cleanup);
|
|
backdrop.addEventListener("click", cleanup);
|
|
saveBtn.addEventListener("click", onSave);
|
|
document.addEventListener("keydown", onKeydown, true);
|
|
|
|
document.body.appendChild(overlay);
|
|
(playlists.length ? saveBtn : newInput).focus();
|
|
});
|
|
}
|
|
|
|
export default openPlaylistPicker;
|