diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 12:10:25 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 12:10:25 +0200 |
| commit | 9cbff21274604e37c0986d57937deef85819c396 (patch) | |
| tree | bdfb55978bb105fd78d1d35db33ff8bb35457f67 /packages/meshbay-hub/src/meshbay_hub | |
| parent | 2e973795383b71f63ae9e3bef0e5dfc7930b4c90 (diff) | |
| download | meshbay-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')
17 files changed, 693 insertions, 24 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index ef8d1e9..38314e7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -19,6 +19,8 @@ import { startIdleWatch, markActive } from './idle.js'; import { GroupPage } from './group-page.js'; import { SearchPage, ConnectionPool } from './search-page.js'; import { MusicPlayerBar } from './music-player.js'; +import { NameModal } from './playlist-menu.js'; +import { saveQueueAsPlaylist } from './playlists.js'; import { IndexingDock } from './index-dock.js'; import { SettingsPage } from './settings-page.js'; import { ProfilePage } from './profile-page.js'; @@ -750,6 +752,11 @@ function App() { const handleStopMusic = useCallback(() => setMusicQueue(null), []); + // Saving the queue is a shell-level action because the queue is: the player + // bar outlives every page, and the account it belongs to is here. + const [saveQueue, setSaveQueue] = useState(null); + const handleSaveQueue = useCallback((rows) => setSaveQueue(rows), []); + const resolved = resolveTheme(theme); // The name of the last session. A failed renewal clears the stored session, @@ -1153,11 +1160,19 @@ function App() { </main> </div> ${user && html`<${IndexingDock} groups=${groups} />`} + ${saveQueue && user && html` + <${NameModal} title=${t('playlists.save_queue')} + onSubmit=${async (name) => { + await saveQueueAsPlaylist(user.userId, name, saveQueue); + }} + onClose=${() => setSaveQueue(null)} /> + `} ${musicQueue && html` <${MusicPlayerBar} getConnection=${getMusicConnection} queue=${musicQueue} userPrefs=${userPrefs} + onSaveQueue=${user ? handleSaveQueue : null} onClose=${handleStopMusic} /> `} <//> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 4cb3e1b..9dec910 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -317,6 +317,28 @@ export default { 'music.player_repeat_one': 'Einzelnen wiederholen', 'music.player_volume': 'Lautstärke', 'music.player_close': 'Player schließen', + 'playlists.menu': 'Playlists', + 'playlists.load': 'Playlist laden', + 'playlists.create': 'Neue Playlist…', + 'playlists.delete': 'Playlist löschen', + 'playlists.remove_track': 'Titel entfernen', + 'playlists.sync_now': 'Jetzt synchronisieren', + 'playlists.add_to': 'Zur Playlist hinzufügen', + 'playlists.favorites': 'Favoriten', + 'playlists.none_yet': 'Noch keine Playlists', + 'playlists.empty_playlist': 'Diese Playlist ist leer', + 'playlists.added': '{n} hinzugefügt', + 'playlists.already_there': 'Bereits in dieser Playlist', + 'playlists.name_placeholder': 'Name der Playlist', + 'playlists.err_duplicate': 'Eine Playlist mit diesem Namen existiert bereits', + 'playlists.confirm_delete': 'Playlist „{name}“ löschen? Das lässt sich nicht rückgängig machen.', + 'playlists.deleted': '„{name}“ gelöscht', + 'playlists.synced': 'Playlists synchronisiert', + 'playlists.sync_failed': 'Synchronisierung fehlgeschlagen', + 'playlists.track_removed': 'Titel entfernt', + 'playlists.save_queue': 'Warteschlange als Playlist speichern…', + 'playlists.cancel': 'Abbrechen', + 'playlists.save': 'Speichern', 'music.player_queue': 'Aktuelle Wiedergabeliste', 'music.queue_title': 'Wird wiedergegeben', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index cc94d87..37c7c1b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -315,6 +315,28 @@ export default { 'music.player_repeat_one': 'Repeat one', 'music.player_volume': 'Volume', 'music.player_close': 'Close player', + 'playlists.menu': 'Playlists', + 'playlists.load': 'Load a playlist', + 'playlists.create': 'New playlist…', + 'playlists.delete': 'Delete a playlist', + 'playlists.remove_track': 'Remove a track', + 'playlists.sync_now': 'Sync now', + 'playlists.add_to': 'Add to playlist', + 'playlists.favorites': 'Favourites', + 'playlists.none_yet': 'No playlists yet', + 'playlists.empty_playlist': 'This playlist is empty', + 'playlists.added': '{n} added', + 'playlists.already_there': 'Already in that playlist', + 'playlists.name_placeholder': 'Playlist name', + 'playlists.err_duplicate': 'A playlist by that name already exists', + 'playlists.confirm_delete': 'Delete the playlist "{name}"? This cannot be undone.', + 'playlists.deleted': '"{name}" deleted', + 'playlists.synced': 'Playlists synced', + 'playlists.sync_failed': 'Could not sync playlists', + 'playlists.track_removed': 'Track removed', + 'playlists.save_queue': 'Save the queue as a playlist…', + 'playlists.cancel': 'Cancel', + 'playlists.save': 'Save', 'music.player_queue': 'Current queue', 'music.queue_title': 'Playing now', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 8ce9d45..3edc78a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -315,6 +315,28 @@ export default { 'music.player_repeat_one': 'Repetir una', 'music.player_volume': 'Volumen', 'music.player_close': 'Cerrar reproductor', + 'playlists.menu': 'Listas', + 'playlists.load': 'Cargar una lista', + 'playlists.create': 'Nueva lista…', + 'playlists.delete': 'Eliminar una lista', + 'playlists.remove_track': 'Quitar una pista', + 'playlists.sync_now': 'Sincronizar ahora', + 'playlists.add_to': 'Añadir a una lista', + 'playlists.favorites': 'Favoritos', + 'playlists.none_yet': 'Aún no hay listas', + 'playlists.empty_playlist': 'Esta lista está vacía', + 'playlists.added': '{n} añadidas', + 'playlists.already_there': 'Ya está en esa lista', + 'playlists.name_placeholder': 'Nombre de la lista', + 'playlists.err_duplicate': 'Ya existe una lista con ese nombre', + 'playlists.confirm_delete': '¿Eliminar la lista «{name}»? Esto no se puede deshacer.', + 'playlists.deleted': '«{name}» eliminada', + 'playlists.synced': 'Listas sincronizadas', + 'playlists.sync_failed': 'No se pudieron sincronizar', + 'playlists.track_removed': 'Pista quitada', + 'playlists.save_queue': 'Guardar la cola como lista…', + 'playlists.cancel': 'Cancelar', + 'playlists.save': 'Guardar', 'music.player_queue': 'Cola actual', 'music.queue_title': 'Reproduciendo ahora', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 2caf912..c7e1ed3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -316,6 +316,28 @@ export default { 'music.player_repeat_one': 'Répéter le morceau', 'music.player_volume': 'Volume', 'music.player_close': 'Fermer le lecteur', + 'playlists.menu': 'Playlists', + 'playlists.load': 'Charger une playlist', + 'playlists.create': 'Nouvelle playlist…', + 'playlists.delete': 'Supprimer une playlist', + 'playlists.remove_track': 'Retirer un morceau', + 'playlists.sync_now': 'Synchroniser maintenant', + 'playlists.add_to': 'Ajouter à une playlist', + 'playlists.favorites': 'Favoris', + 'playlists.none_yet': 'Aucune playlist', + 'playlists.empty_playlist': 'Cette playlist est vide', + 'playlists.added': '{n} ajouté(s)', + 'playlists.already_there': 'Déjà dans cette playlist', + 'playlists.name_placeholder': 'Nom de la playlist', + 'playlists.err_duplicate': 'Une playlist porte déjà ce nom', + 'playlists.confirm_delete': 'Supprimer la playlist « {name} » ? Cette action est irréversible.', + 'playlists.deleted': '« {name} » supprimée', + 'playlists.synced': 'Playlists synchronisées', + 'playlists.sync_failed': 'Synchronisation impossible', + 'playlists.track_removed': 'Morceau retiré', + 'playlists.save_queue': 'Enregistrer la file comme playlist…', + 'playlists.cancel': 'Annuler', + 'playlists.save': 'Enregistrer', 'music.player_queue': 'File en cours', 'music.queue_title': 'En cours de lecture', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 5edfb24..bfeb20d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -316,6 +316,28 @@ export default { 'music.player_repeat_one': 'Ripeti brano', 'music.player_volume': 'Volume', 'music.player_close': 'Chiudi lettore', + 'playlists.menu': 'Playlist', + 'playlists.load': 'Carica una playlist', + 'playlists.create': 'Nuova playlist…', + 'playlists.delete': 'Elimina una playlist', + 'playlists.remove_track': 'Rimuovi un brano', + 'playlists.sync_now': 'Sincronizza ora', + 'playlists.add_to': 'Aggiungi a una playlist', + 'playlists.favorites': 'Preferiti', + 'playlists.none_yet': 'Nessuna playlist', + 'playlists.empty_playlist': 'Questa playlist è vuota', + 'playlists.added': '{n} aggiunti', + 'playlists.already_there': 'Già in quella playlist', + 'playlists.name_placeholder': 'Nome della playlist', + 'playlists.err_duplicate': 'Esiste già una playlist con questo nome', + 'playlists.confirm_delete': 'Eliminare la playlist «{name}»? Non è reversibile.', + 'playlists.deleted': '«{name}» eliminata', + 'playlists.synced': 'Playlist sincronizzate', + 'playlists.sync_failed': 'Sincronizzazione non riuscita', + 'playlists.track_removed': 'Brano rimosso', + 'playlists.save_queue': 'Salva la coda come playlist…', + 'playlists.cancel': 'Annulla', + 'playlists.save': 'Salva', 'music.player_queue': 'Coda attuale', 'music.queue_title': 'In riproduzione', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 29fd626..caf729a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -313,6 +313,28 @@ export default { 'music.player_repeat_one': '1曲リピート', 'music.player_volume': '音量', 'music.player_close': 'プレーヤーを閉じる', + 'playlists.menu': 'プレイリスト', + 'playlists.load': 'プレイリストを読み込む', + 'playlists.create': '新しいプレイリスト…', + 'playlists.delete': 'プレイリストを削除', + 'playlists.remove_track': '曲を削除', + 'playlists.sync_now': '今すぐ同期', + 'playlists.add_to': 'プレイリストに追加', + 'playlists.favorites': 'お気に入り', + 'playlists.none_yet': 'プレイリストがありません', + 'playlists.empty_playlist': 'このプレイリストは空です', + 'playlists.added': '{n} 件追加しました', + 'playlists.already_there': 'すでにこのプレイリストにあります', + 'playlists.name_placeholder': 'プレイリスト名', + 'playlists.err_duplicate': '同じ名前のプレイリストがあります', + 'playlists.confirm_delete': 'プレイリスト「{name}」を削除しますか?元に戻せません。', + 'playlists.deleted': '「{name}」を削除しました', + 'playlists.synced': 'プレイリストを同期しました', + 'playlists.sync_failed': '同期できませんでした', + 'playlists.track_removed': '曲を削除しました', + 'playlists.save_queue': 'キューをプレイリストとして保存…', + 'playlists.cancel': 'キャンセル', + 'playlists.save': '保存', 'music.player_queue': '再生中のキュー', 'music.queue_title': '再生中', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index f4605b3..b9e719e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -317,6 +317,28 @@ export default { 'music.player_repeat_one': 'Nummer herhalen', 'music.player_volume': 'Volume', 'music.player_close': 'Speler sluiten', + 'playlists.menu': 'Afspeellijsten', + 'playlists.load': 'Afspeellijst laden', + 'playlists.create': 'Nieuwe afspeellijst…', + 'playlists.delete': 'Afspeellijst verwijderen', + 'playlists.remove_track': 'Nummer verwijderen', + 'playlists.sync_now': 'Nu synchroniseren', + 'playlists.add_to': 'Aan afspeellijst toevoegen', + 'playlists.favorites': 'Favorieten', + 'playlists.none_yet': 'Nog geen afspeellijsten', + 'playlists.empty_playlist': 'Deze afspeellijst is leeg', + 'playlists.added': '{n} toegevoegd', + 'playlists.already_there': 'Staat er al in', + 'playlists.name_placeholder': 'Naam van de afspeellijst', + 'playlists.err_duplicate': 'Er bestaat al een afspeellijst met die naam', + 'playlists.confirm_delete': 'Afspeellijst “{name}” verwijderen? Dit kan niet ongedaan worden gemaakt.', + 'playlists.deleted': '“{name}” verwijderd', + 'playlists.synced': 'Afspeellijsten gesynchroniseerd', + 'playlists.sync_failed': 'Synchroniseren mislukt', + 'playlists.track_removed': 'Nummer verwijderd', + 'playlists.save_queue': 'Wachtrij opslaan als afspeellijst…', + 'playlists.cancel': 'Annuleren', + 'playlists.save': 'Opslaan', 'music.player_queue': 'Huidige wachtrij', 'music.queue_title': 'Nu aan het afspelen', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 3b65ff9..7f87537 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -326,6 +326,28 @@ export default { 'music.player_repeat_one': 'Powtórz utwór', 'music.player_volume': 'Głośność', 'music.player_close': 'Zamknij odtwarzacz', + 'playlists.menu': 'Playlisty', + 'playlists.load': 'Wczytaj playlistę', + 'playlists.create': 'Nowa playlista…', + 'playlists.delete': 'Usuń playlistę', + 'playlists.remove_track': 'Usuń utwór', + 'playlists.sync_now': 'Synchronizuj teraz', + 'playlists.add_to': 'Dodaj do playlisty', + 'playlists.favorites': 'Ulubione', + 'playlists.none_yet': 'Brak playlist', + 'playlists.empty_playlist': 'Ta playlista jest pusta', + 'playlists.added': 'Dodano: {n}', + 'playlists.already_there': 'Już jest na tej playliście', + 'playlists.name_placeholder': 'Nazwa playlisty', + 'playlists.err_duplicate': 'Playlista o tej nazwie już istnieje', + 'playlists.confirm_delete': 'Usunąć playlistę „{name}”? Tego nie można cofnąć.', + 'playlists.deleted': 'Usunięto „{name}”', + 'playlists.synced': 'Playlisty zsynchronizowane', + 'playlists.sync_failed': 'Nie udało się zsynchronizować', + 'playlists.track_removed': 'Utwór usunięty', + 'playlists.save_queue': 'Zapisz kolejkę jako playlistę…', + 'playlists.cancel': 'Anuluj', + 'playlists.save': 'Zapisz', 'music.player_queue': 'Aktualna kolejka', 'music.queue_title': 'Teraz odtwarzane', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 7ed921c..961b353 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -317,6 +317,28 @@ export default { 'music.player_repeat_one': 'Repetir faixa', 'music.player_volume': 'Volume', 'music.player_close': 'Fechar player', + 'playlists.menu': 'Playlists', + 'playlists.load': 'Carregar uma playlist', + 'playlists.create': 'Nova playlist…', + 'playlists.delete': 'Excluir uma playlist', + 'playlists.remove_track': 'Remover uma faixa', + 'playlists.sync_now': 'Sincronizar agora', + 'playlists.add_to': 'Adicionar à playlist', + 'playlists.favorites': 'Favoritos', + 'playlists.none_yet': 'Nenhuma playlist ainda', + 'playlists.empty_playlist': 'Esta playlist está vazia', + 'playlists.added': '{n} adicionadas', + 'playlists.already_there': 'Já está nessa playlist', + 'playlists.name_placeholder': 'Nome da playlist', + 'playlists.err_duplicate': 'Já existe uma playlist com esse nome', + 'playlists.confirm_delete': 'Excluir a playlist "{name}"? Isso não pode ser desfeito.', + 'playlists.deleted': '"{name}" excluída', + 'playlists.synced': 'Playlists sincronizadas', + 'playlists.sync_failed': 'Não foi possível sincronizar', + 'playlists.track_removed': 'Faixa removida', + 'playlists.save_queue': 'Salvar a fila como playlist…', + 'playlists.cancel': 'Cancelar', + 'playlists.save': 'Salvar', 'music.player_queue': 'Fila atual', 'music.queue_title': 'Tocando agora', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 280b3aa..893b2eb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -310,6 +310,28 @@ export default { 'music.player_repeat_one': '单曲重复', 'music.player_volume': '音量', 'music.player_close': '关闭播放器', + 'playlists.menu': '播放列表', + 'playlists.load': '加载播放列表', + 'playlists.create': '新建播放列表…', + 'playlists.delete': '删除播放列表', + 'playlists.remove_track': '移除曲目', + 'playlists.sync_now': '立即同步', + 'playlists.add_to': '添加到播放列表', + 'playlists.favorites': '收藏', + 'playlists.none_yet': '还没有播放列表', + 'playlists.empty_playlist': '此播放列表为空', + 'playlists.added': '已添加 {n} 首', + 'playlists.already_there': '已在该播放列表中', + 'playlists.name_placeholder': '播放列表名称', + 'playlists.err_duplicate': '已存在同名播放列表', + 'playlists.confirm_delete': '删除播放列表“{name}”?此操作无法撤销。', + 'playlists.deleted': '已删除“{name}”', + 'playlists.synced': '播放列表已同步', + 'playlists.sync_failed': '无法同步', + 'playlists.track_removed': '已移除曲目', + 'playlists.save_queue': '将队列保存为播放列表…', + 'playlists.cancel': '取消', + 'playlists.save': '保存', 'music.player_queue': '当前队列', 'music.queue_title': '正在播放', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/menu.js b/packages/meshbay-hub/src/meshbay_hub/static/menu.js index 4aa08ca..1c6a96a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/menu.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/menu.js @@ -19,9 +19,15 @@ import { Icon } from './icon.js'; * downward needs no flipping, no hover intent, and no separate mobile design. * * `items` is a flat list, each entry one of: - * { label, icon?, onSelect } an action - * { label, icon?, items, empty? } a submenu, expanded in place + * { label, icon?, onSelect } an action + * { label, icon?, items, empty? } a submenu, expanded in place + * { label, icon?, loadItems, empty? } the same, fetched when expanded * { divider: true } + * + * `loadItems` exists for the one submenu whose contents are not already in + * hand: a playlist's tracklist, which is read out of IndexedDB. Reading every + * playlist's tracks to build a menu nobody may open would mean a ten-thousand + * track list read on every click of the button. */ // Kept away from the viewport edges; `.ctx-menu` sets the width this assumes. @@ -54,25 +60,44 @@ function useMenu() { function MenuItems({ items, depth, onClose }) { const [expanded, setExpanded] = useState(null); + // index -> the items that came back, or 'loading'. + const [loaded, setLoaded] = useState({}); + + const toggle = useCallback((i, it) => { + if (expanded === i) { setExpanded(null); return; } + setExpanded(i); + if (!it.loadItems || loaded[i] !== undefined) return; + setLoaded((prev) => ({ ...prev, [i]: 'loading' })); + Promise.resolve(it.loadItems()) + .then((rows) => setLoaded((prev) => ({ ...prev, [i]: rows || [] }))) + // A submenu that cannot be filled renders as empty rather than as a + // spinner nothing will ever replace. + .catch(() => setLoaded((prev) => ({ ...prev, [i]: [] }))); + }, [expanded, loaded]); return html` ${items.map((it, i) => { if (it.divider) return html`<div class="ctx-menu-divider" key=${`d${i}`}></div>`; - if (it.items) { + if (it.items || it.loadItems) { const open = expanded === i; + const rows = it.items || loaded[i]; + const pending = rows === 'loading' || (it.loadItems && rows === undefined); return html` <button class="ctx-menu-item" key=${it.key || it.label} style=${depth ? `padding-left: ${14 + depth * 14}px` : null} - onClick=${(e) => { e.stopPropagation(); setExpanded(open ? null : i); }}> + onClick=${(e) => { e.stopPropagation(); toggle(i, it); }}> ${it.icon && html`<${Icon} name=${it.icon} cls="ctx-menu-icon" />`} <span class="ctx-menu-label">${it.label}</span> <${Icon} name="chevron" cls="ctx-menu-caret ${open ? 'flip' : ''}" /> </button> - ${open && (it.items.length - ? html`<${MenuItems} items=${it.items} depth=${depth + 1} onClose=${onClose} />` - : html`<div class="ctx-menu-empty" - style=${`padding-left: ${28 + depth * 14}px`}>${it.empty || ''}</div>`)} + ${open && (pending + ? html`<div class="ctx-menu-empty" + style=${`padding-left: ${28 + depth * 14}px`}><span class="spinner"></span></div>` + : (rows && rows.length + ? html`<${MenuItems} items=${rows} depth=${depth + 1} onClose=${onClose} />` + : html`<div class="ctx-menu-empty" + style=${`padding-left: ${28 + depth * 14}px`}>${it.empty || ''}</div>`))} `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js index 15fbcf9..8fc6cb3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js @@ -8,6 +8,8 @@ import { formatTime } from './music-player.js'; import { SourceTag } from './group-name.js'; import { usePager, Pager, pageSizeFrom } from './pager.js'; import { Menu, MenuDots, useMenu } from './menu.js'; +import { PlaylistMenuButton, NameModal, usePlaylists } from './playlist-menu.js'; +import * as P from './playlists.js'; // -- Music -------------------------------------------------------------------- // @@ -497,7 +499,7 @@ function FlatList({ items, onPlayQueue, onMenu }) { function MusicApp({ groupId, transportRef, gekRef, status, entries, availableEntries, - musicDirectories, musicbrainzConfig, onPlayQueue, + musicDirectories, musicbrainzConfig, onPlayQueue, userId, hideFilter, userPrefs, pageResetKey, }) { const [mode, setMode] = useState(loadViewMode); @@ -506,6 +508,42 @@ function MusicApp({ // One menu for the whole view. Per-card state would mean a hundred open // handlers on a full grid, and two menus could be open at once. const { menu, openAt, close: closeMenu } = useMenu(); + // One list, shared by the toolbar button and the per-item submenu below. + const { lists: playlists, reload: reloadPlaylists } = usePlaylists(userId); + const [pendingAdd, setPendingAdd] = useState(null); + const [note, setNote] = useState(''); + + const say = useCallback((text) => { + setNote(text); + setTimeout(() => setNote(''), 4000); + }, []); + + // Sync rides the connection this group already has open — §7's whole point + // is that playlists add no dialing. Once per group opened, and again only + // when the reader asks. + const syncNow = useCallback(async () => { + const tr = transportRef && transportRef.current; + if (!tr || !userId) return { ok: false, reason: 'offline' }; + const r = await P.syncWith(tr, userId); + await reloadPlaylists(); + return r; + }, [transportRef, userId, reloadPlaylists]); + + useEffect(() => { + if (status !== 'connected' || !userId) return; + syncNow().catch(() => {}); + }, [status, userId, groupId]); + + const addToPlaylist = useCallback(async (id, tracks) => { + const added = await P.addTracks(userId, id, tracks, groupId, t('playlists.favorites')); + await reloadPlaylists(); + say(added + ? t('playlists.added', { n: added }) + : t('playlists.already_there')); + // Straight on to whatever node this group is on, so the edit is not only + // in this browser. Best-effort: it is durable locally either way. + syncNow().catch(() => {}); + }, [userId, groupId, reloadPlaylists, say, syncNow]); // The queue verbs, for an album (every track, from the first) or for one // track. `startIndex` only means anything to "play": the other two do not @@ -523,8 +561,33 @@ function MusicApp({ onSelect: () => onPlayQueue(tracks, 0, 'next') }, { label: t('music.menu_enqueue'), icon: 'plus', onSelect: () => onPlayQueue(tracks, 0, 'append') }, + { divider: true }, + { + label: t('playlists.add_to'), icon: 'playlist', + // Drawn from the manifest, so it opens instantly with every node + // offline. Favourites is first, and is there on a fresh account + // because `livePlaylists` puts the reserved id first whether or not + // it has been used yet. + items: [ + ...(playlists.some((p) => p.id === P.FAVORITES_ID) ? [] : [{ + key: P.FAVORITES_ID, label: t('playlists.favorites'), icon: 'check', + onSelect: () => addToPlaylist(P.FAVORITES_ID, tracks), + }]), + ...playlists.map((p) => ({ + key: p.id, + label: p.id === P.FAVORITES_ID ? t('playlists.favorites') : p.name, + hint: t('music.n_tracks', { n: p.count || 0 }), + onSelect: () => addToPlaylist(p.id, tracks), + })), + { divider: true }, + { + label: t('playlists.create'), icon: 'plus', + onSelect: () => setPendingAdd(tracks), + }, + ], + }, ]); - }, [openAt, onPlayQueue]); + }, [openAt, onPlayQueue, playlists, addToPlaylist]); useEffect(() => { setMode(loadViewMode()); }, [groupId]); useEffect(() => { setFilter(''); }, [groupId]); @@ -592,6 +655,9 @@ function MusicApp({ onClick=${() => setModeAndSave('flat')}> ${t('music.mode_flat')} </button> + ${userId && html`<${PlaylistMenuButton} userId=${userId} + lists=${playlists} reload=${reloadPlaylists} + onPlayQueue=${onPlayQueue} onSync=${syncNow} />`} <${Pager} pager=${pager} /> ${!hideFilter && html`<div class="tb-search"> <${Icon} name="search" /> @@ -611,6 +677,15 @@ function MusicApp({ onPlayQueue=${onPlayQueue} onMenu=${onMenu} />`} `} ${menu && html`<${Menu} ...${menu} onClose=${closeMenu} />`} + ${note && html`<div class="playlist-note">${note}</div>`} + ${pendingAdd && html` + <${NameModal} title=${t('playlists.create')} + onSubmit=${async (name) => { + const id = await P.createPlaylist(userId, name); + await addToPlaylist(id, pendingAdd); + }} + onClose=${() => setPendingAdd(null)} /> + `} `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js index fcf122f..c3a2868 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js @@ -119,7 +119,7 @@ function saveRepeat(v) { // opening this told you nothing you didn't already know. It now shows the // current track's own title/artist right under the header, and scrolls the // highlighted row into view on open rather than leaving it to be found. -function QueuePanel({ tracks, order, pos, onSelect, onClose }) { +function QueuePanel({ tracks, order, pos, onSelect, onClose, onSaveAsPlaylist }) { const activeRowRef = useRef(null); useEffect(() => { if (activeRowRef.current) { @@ -136,6 +136,16 @@ function QueuePanel({ tracks, order, pos, onSelect, onClose }) { <div class="music-detail"> <div class="video-top-bar"> <span class="video-title">${t('music.queue_title')}</span> + ${/* "Save the current queue as a playlist" lives here rather than in + Music's toolbar menu, because this panel is where the current + queue is a thing the reader can actually see — and because the + queue is the player's own state, which a menu in a different + component would have to have lifted out of it. */''} + ${onSaveAsPlaylist && html` + <button class="video-close" title=${t('playlists.save_queue')} + onClick=${() => onSaveAsPlaylist(order.map((i) => tracks[i]))}> + <${Icon} name="playlist" /></button> + `} <button class="video-close" onClick=${onClose} title=${t('video.close')}> <${Icon} name="close" /></button> </div> @@ -165,7 +175,7 @@ function QueuePanel({ tracks, order, pos, onSelect, onClose }) { `; } -function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { +function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue }) { // Pinned to the bottom of the window, over the bottom of the sidebar; the // sidebar subtracts this so its last entry is not underneath. const barBand = useStickyBand('--music-bar-h'); @@ -199,15 +209,51 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { const consecutiveFailuresRef = useRef(0); const MAX_CONSECUTIVE_FAILURES = 5; const prefetchAfterInsertRef = useRef(false); + // Groups that did not answer this session. A playlist crosses groups, and + // one of them being off is a property of that group rather than of each of + // its tracks in turn — see advancePastFailure below (docs/playlists.md §11.3). + const downGroupsRef = useRef(new Set()); const currentTrack = tracks[order[pos]] || null; - const advancePastFailure = useCallback(() => { - consecutiveFailuresRef.current += 1; - if (consecutiveFailuresRef.current > MAX_CONSECUTIVE_FAILURES || order.length <= 1) return; - if (pos + 1 < order.length) dispatch({ type: 'skipTo', pos: pos + 1 }); - else if (repeat === 'all') dispatch({ type: 'skipTo', pos: 0 }); - }, [order.length, pos, repeat]); + /** + * Move past a track that will not play. + * + * Two failures, and they are properties of different things: + * + * A **decode** failure belongs to that file — a truncated download, a format + * with no decoder. The bounded counter is right for it: a queue that turns + * out to be entirely bad fails once, visibly, rather than burning through + * the whole list in an instant. + * + * A **connection** failure belongs to that *group*. The same bound applied + * to it is a regression playlists introduce into code that is correct today: + * a playlist whose next six tracks all come from one node that is off stops + * at the sixth, with an error, and the reader sees "the playlist is broken". + * So the group is marked down and every one of its queued tracks is skipped + * in one step, with the counter reset — which is what the bound was + * protecting in the first place. + */ + const advancePastFailure = useCallback((groupDown) => { + if (order.length <= 1) return; + let next = pos + 1; + if (groupDown) { + downGroupsRef.current.add(groupDown); + consecutiveFailuresRef.current = 0; + while (next < order.length + && downGroupsRef.current.has(tracks[order[next]] + && tracks[order[next]].groupId)) { + next += 1; + } + } else { + consecutiveFailuresRef.current += 1; + if (consecutiveFailuresRef.current > MAX_CONSECUTIVE_FAILURES) return; + } + if (next < order.length) { dispatch({ type: 'skipTo', pos: next }); return; } + if (repeat === 'all') dispatch({ type: 'skipTo', pos: 0 }); + // Nothing left that can play. Stopping once, with the error already on + // screen, is the honest end — and is what the bound above exists to reach. + }, [tracks, order, pos, repeat]); // Stops playback the moment this bar goes away for any reason -- the // close button below, or the shell tearing it down on its own (leaving @@ -279,10 +325,23 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { const fetchTrackBlob = useCallback(async (entry) => { const cached = blobCacheRef.current.get(entry.id); if (cached) return cached.url; - const { transport, gek } = await getConnection(entry.groupId); - if (!transport) throw new Error(t('music.err_transport')); + // A group that does not answer is marked as such, so the queue can skip + // all of its tracks at once rather than one failure at a time. + const groupDown = () => { + const e = new Error(t('music.err_transport')); + e.isGroupDown = true; + return e; + }; + let transport; + let gek; + try { + ({ transport, gek } = await getConnection(entry.groupId)); + } catch { + throw groupDown(); + } + if (!transport) throw groupDown(); if (!transport.connected) await transport.waitForReconnect(); - if (!transport.connected) throw new Error(t('music.err_transport')); + if (!transport.connected) throw groupDown(); let downloadId = entry.id; let downloadSize = entry.size; @@ -319,9 +378,18 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { // afterward — see prefetchDepth() for how far ahead that runway goes. const prefetchNext = useCallback((fromPos) => { const ahead = prefetchDepth(); + const playingGroup = tracks[order[fromPos]] && tracks[order[fromPos]].groupId; for (let i = 1; i <= ahead; i++) { const nextEntry = tracks[order[fromPos + i]]; if (!nextEntry || blobCacheRef.current.has(nextEntry.id)) continue; + // Only what this queue is already connected to. For an album these five + // share one connection and nothing changes; for a shuffled cross-group + // playlist they may want five *different* node dials of up to ten + // seconds each, against a pool of twelve — to warm tracks the reader may + // never reach (docs/playlists.md §9.5). The rest warm when the queue + // gets to them and the dial has to happen anyway. + if (nextEntry.groupId !== playingGroup) continue; + if (downGroupsRef.current.has(nextEntry.groupId)) continue; fetchTrackBlob(nextEntry).catch(() => {}); } }, [tracks, order, fetchTrackBlob]); @@ -388,7 +456,9 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { if (loadTokenRef.current !== myToken) return; setError(err.message || String(err)); setPlaying(false); - advancePastFailure(); + // `fetchTrackBlob` throws `err_transport` when no node answered for + // this track's group; anything else is about the file itself. + advancePastFailure(err.isGroupDown ? currentTrack.groupId : null); } finally { if (loadTokenRef.current === myToken) setLoading(false); } @@ -547,7 +617,14 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { </div> ${showQueue && html` <${QueuePanel} tracks=${tracks} order=${order} pos=${pos} - onSelect=${skipTo} onClose=${() => setShowQueue(false)} /> + onSelect=${skipTo} onClose=${() => setShowQueue(false)} + ${/* Saved in **play order**, which is what this panel is showing: if + shuffle is on, that freezes the shuffle, and that is what "save + what I am listening to" means. */''} + onSaveAsPlaylist=${onSaveQueue && ((rows) => { + setShowQueue(false); + onSaveQueue(rows); + })} /> `} `; } 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 }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js index 8e3f17c..593760c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js @@ -790,7 +790,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) entries=${musicEntries} musicDirectories=${[SEARCH_AUDIO_ROOT]} musicbrainzConfig=${{ enabled: true }} - onPlayQueue=${handleMusicPlay} + onPlayQueue=${handleMusicPlay} userId=${userId} userPrefs=${userPrefs} pageResetKey=${q} hideFilter=${true} /> `} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 5642262..2bf0b18 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -5141,3 +5141,54 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } /* "Play all" and the album's menu, side by side under the cover. */ .music-detail-actions { display: flex; align-items: center; gap: 6px; } .music-detail-actions .ctx-dots { opacity: 1; } + +/* ── Playlists (playlist-menu.js) ───────────────────────────────────────── + docs/playlists.md §10.3. One button in Music's sticky toolbar, and the two + small surfaces behind it: a field for naming a playlist, and a one-line + result that clears itself. */ + +.playlist-modal { max-width: 420px; } +.playlist-modal-body { padding: 16px; display: flex; flex-direction: column; gap: 12px; } +.playlist-modal-body input { + width: 100%; + padding: 9px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-base); + color: var(--text); + font: inherit; +} +.playlist-modal-body input:focus { + outline: none; + border-color: var(--border-focus); +} +.playlist-modal-error { color: var(--error); font-size: 0.85em; } +.playlist-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +/* The result of an action, said once and then gone. Not a dialog: adding an + album to a playlist is not a thing anyone should have to dismiss. Fixed + above the player bar, which is itself pinned to the bottom — `--music-bar-h` + is published by the bar and is 0 when there is no bar. */ +.playlist-note { + position: fixed; + left: 50%; + bottom: calc(var(--music-bar-h, 0px) + 16px + env(safe-area-inset-bottom, 0px)); + transform: translateX(-50%); + z-index: 250; + max-width: calc(100vw - 32px); + padding: 9px 16px; + border-radius: 999px; + background: var(--bg-surface); + border: 1px solid var(--border); + box-shadow: var(--shadow-lg); + color: var(--text); + font-size: 0.85em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} |