aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 12:10:25 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 12:10:25 +0200
commit9cbff21274604e37c0986d57937deef85819c396 (patch)
treebdfb55978bb105fd78d1d35db33ff8bb35457f67 /packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js
parent2e973795383b71f63ae9e3bef0e5dfc7930b4c90 (diff)
downloadmeshbay-9cbff21274604e37c0986d57937deef85819c396.tar.gz
music: the playlist menus
One button in Music's sticky toolbar — load, create, delete, remove a track, sync now — and "add to playlist" on every cover and row. Both surfaces share one list, read from the manifest, so they open instantly with every node offline and no body is fetched until one is wanted. Submenus expand in place rather than flying out: the account menu's language list already does this, and a flyout has nowhere to go at 400px. The tracklist under "remove a track" loads when it is expanded. A name is typed into a field. Electron has no prompt — it throws. Also splits the two playback failures: a decode failure belongs to that file and keeps the bounded counter, a connection failure belongs to the group and skips all of its queued tracks at once. Six dead tracks are one more than the bound, which is where a playlist would otherwise stop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js206
1 files changed, 206 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js b/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js
new file mode 100644
index 0000000..8d85b68
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js
@@ -0,0 +1,206 @@
+import {
+ html, useState, useCallback, useEffect,
+} from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+import { Menu, useMenu } from './menu.js';
+import * as P from './playlists.js';
+
+/**
+ * The playlist verbs, behind one button in Music's toolbar.
+ *
+ * `docs/playlists.md` §10.3. One button and one icon, no label: the toolbar
+ * already wraps to three rows at a phone width and has no room for a word, and
+ * it sits in the sticky band so it is reachable at any scroll position.
+ *
+ * Mounted by `music-app.js`, which the group page and the Search page both
+ * render — so a playlist built inside a group is managed from the consolidated
+ * view with no second surface and no application-registry entry.
+ *
+ * **Every list here is drawn from the manifest**, which is a few kilobytes and
+ * always in IndexedDB. The menu opens instantly with every node offline, and no
+ * playlist body is read until one is actually wanted. The one exception is the
+ * tracklist under "remove a track", which is fetched when that submenu is
+ * expanded and not before.
+ */
+
+// ── modals ───────────────────────────────────────────────────────────────────
+//
+// A field, never `window.prompt`: Electron does not implement prompt, and it
+// does not return null — it throws, which is how the Files toolbar's New folder
+// button came to do nothing at all (`test_no_prompt_in_the_spa.py`).
+
+function NameModal({ title, initial, onSubmit, onClose }) {
+ const [name, setName] = useState(initial || '');
+ const [error, setError] = useState('');
+ const [busy, setBusy] = useState(false);
+
+ const submit = async (e) => {
+ e.preventDefault();
+ if (!name.trim() || busy) return;
+ setBusy(true);
+ try {
+ await onSubmit(name.trim());
+ onClose();
+ } catch (err) {
+ setError(err.message === 'duplicate name'
+ ? t('playlists.err_duplicate') : (err.message || String(err)));
+ setBusy(false);
+ }
+ };
+
+ return html`
+ <div class="video-overlay" onClick=${(e) => {
+ if (e.target.classList.contains('video-overlay')) onClose();
+ }}>
+ <form class="music-detail playlist-modal" onSubmit=${submit}>
+ <div class="video-top-bar">
+ <span class="video-title">${title}</span>
+ <button type="button" class="video-close" onClick=${onClose}
+ title=${t('video.close')}><${Icon} name="close" /></button>
+ </div>
+ <div class="playlist-modal-body">
+ <input type="text" autofocus value=${name} maxlength="120"
+ placeholder=${t('playlists.name_placeholder')}
+ onInput=${(e) => { setName(e.target.value); setError(''); }} />
+ ${error && html`<div class="playlist-modal-error">${error}</div>`}
+ <div class="playlist-modal-actions">
+ <button type="button" class="tb-btn" onClick=${onClose}>
+ ${t('playlists.cancel')}</button>
+ <button type="submit" class="admin-btn" disabled=${!name.trim() || busy}>
+ ${t('playlists.save')}</button>
+ </div>
+ </div>
+ </form>
+ </div>
+ `;
+}
+
+// ── the button ───────────────────────────────────────────────────────────────
+
+/**
+ * The account's playlists, from the manifest, and a way to re-read them.
+ *
+ * Held once by whoever mounts both surfaces — the toolbar button and the
+ * per-item "add to playlist" submenu are two views of one list, and two copies
+ * of it would drift the moment either one wrote.
+ */
+function usePlaylists(userId) {
+ const [lists, setLists] = useState([]);
+
+ const reload = useCallback(async () => {
+ if (!userId) { setLists([]); return; }
+ try { setLists(await P.listPlaylists(userId)); } catch { setLists([]); }
+ }, [userId]);
+
+ useEffect(() => { reload(); }, [reload]);
+
+ return { lists, reload };
+}
+
+function PlaylistMenuButton({ userId, lists, reload, onPlayQueue, onSync, cachedIndexes }) {
+ const { menu, openAt, close } = useMenu();
+ const [modal, setModal] = useState(null);
+ const [note, setNote] = useState('');
+
+ // A one-line result, self-clearing: "42 tracks added", "2 nodes unreachable".
+ // Enough to know it happened, never a dialog to dismiss.
+ const say = useCallback((text) => {
+ setNote(text);
+ setTimeout(() => setNote(''), 4000);
+ }, []);
+
+ const loadPlaylist = useCallback(async (id) => {
+ const tracks = await P.getPlaylistTracks(userId, id, cachedIndexes);
+ if (!tracks.length) { say(t('playlists.empty_playlist')); return; }
+ onPlayQueue(tracks, 0);
+ }, [userId, onPlayQueue, cachedIndexes, say]);
+
+ const removeTrack = useCallback(async (id, at) => {
+ await P.removeTrackAt(userId, id, at);
+ await reload();
+ say(t('playlists.track_removed'));
+ }, [userId, reload, say]);
+
+ const deletePlaylist = useCallback(async (p) => {
+ // `confirm` and not a component: Electron implements it, a dozen places in
+ // this SPA already use it, and a deletion is a tombstone rather than
+ // something that can be undone from the interface.
+ if (!window.confirm(t('playlists.confirm_delete', { name: p.name }))) return;
+ await P.deletePlaylist(userId, p.id);
+ await reload();
+ say(t('playlists.deleted', { name: p.name }));
+ }, [userId, reload, say]);
+
+ const openMenu = useCallback((e) => {
+ openAt(e, [
+ {
+ label: t('playlists.load'), icon: 'play',
+ items: lists.map((p) => ({
+ key: p.id, label: p.name,
+ hint: t('music.n_tracks', { n: p.count || 0 }),
+ onSelect: () => loadPlaylist(p.id),
+ })),
+ empty: t('playlists.none_yet'),
+ },
+ {
+ label: t('playlists.create'), icon: 'plus',
+ onSelect: () => setModal({ kind: 'create' }),
+ },
+ {
+ label: t('playlists.delete'), icon: 'trash',
+ // Favourites is never offered: it is refused by the store anyway, and
+ // offering an action that always fails is worse than not offering it.
+ items: lists.filter((p) => p.id !== P.FAVORITES_ID).map((p) => ({
+ key: p.id, label: p.name, danger: true,
+ onSelect: () => deletePlaylist(p),
+ })),
+ empty: t('playlists.none_yet'),
+ },
+ {
+ label: t('playlists.remove_track'), icon: 'close',
+ // Two levels, as asked. The second is fetched when it is expanded and
+ // not before — building it eagerly would read every playlist's tracks
+ // to draw a menu nobody may open.
+ items: lists.map((p) => ({
+ key: p.id, label: p.name,
+ empty: t('playlists.empty_playlist'),
+ loadItems: async () => {
+ const tracks = await P.getPlaylistTracks(userId, p.id);
+ return tracks.map((tr, i) => ({
+ key: `${p.id}:${i}`, danger: true,
+ label: tr.display_title || tr.name,
+ hint: tr.artist || '',
+ onSelect: () => removeTrack(p.id, i),
+ }));
+ },
+ })),
+ empty: t('playlists.none_yet'),
+ },
+ { divider: true },
+ {
+ label: t('playlists.sync_now'), icon: 'refresh',
+ onSelect: async () => {
+ const r = await onSync();
+ await reload();
+ say(r && r.ok ? t('playlists.synced') : t('playlists.sync_failed'));
+ },
+ },
+ ]);
+ }, [openAt, lists, userId, loadPlaylist, deletePlaylist, removeTrack, onSync, reload, say]);
+
+ return html`
+ <button class="tb-btn" title=${t('playlists.menu')} onClick=${openMenu}>
+ <${Icon} name="playlist" />
+ </button>
+ ${menu && html`<${Menu} ...${menu} onClose=${close} />`}
+ ${note && html`<div class="playlist-note">${note}</div>`}
+ ${modal && modal.kind === 'create' && html`
+ <${NameModal} title=${t('playlists.create')}
+ onSubmit=${async (name) => { await P.createPlaylist(userId, name); await reload(); }}
+ onClose=${() => setModal(null)} />
+ `}
+ `;
+}
+
+export { PlaylistMenuButton, NameModal, usePlaylists };