aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js
diff options
context:
space:
mode:
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 };