import { html, useState, useCallback, useEffect, } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import { ask } from './ask.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`
`; } // ── 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) => { // Asked first: a deletion is a tombstone rather than something that can be // undone from the interface. if (!await ask(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(); // The reason, not just "it failed": a sync that quietly does nothing // is the one failure mode nobody can act on, and the reason is what // separates an offline node from a wedged one. // On success, say how much moved; on failure, say why. Either way // the reader learns something they can act on — the one thing this // never did. const last = P.lastSync(); const why = (r && r.reason) || last.reason || '?'; // `no_key` is not a fault the reader can do anything about unless it // is spelled out. This browser signed in with its remembered device // key, so the only bundle key it has is one persisted before the // playlist subkey existed — and an AES handle is non-extractable, so // there is nothing to derive it from. One sign-in with the passphrase // fixes it for good; a code on screen does not say that. // A playlist too large to send is reported ahead of the totals. The // sync itself worked — everything else moved — so `ok` is true, and // saying "synced" while one playlist silently stayed behind would be // the lie this whole report exists to stop. const big = (r && r.tooLarge) || []; say(big.length ? t('playlists.err_too_large', { name: big[0], n: 500 }) : (r && r.ok ? `${t('playlists.synced')} (${r.pushed || 0}↑ ${r.pulled || 0}↓)` : (why === 'no_key' ? t('playlists.err_no_key') : `${t('playlists.sync_failed')}: ${why}`))); }, }, ]); }, [openAt, lists, userId, loadPlaylist, deletePlaylist, removeTrack, onSync, reload, say]); return html` ${menu && html`<${Menu} ...${menu} onClose=${close} />`} ${note && html`