summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js
blob: a65d26da5b6f2493d17e3940cc4260f3136ef807 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
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`
    <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) => {
    // 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`
    <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 };