diff options
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.js | 206 |
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 }; |