From 9cbff21274604e37c0986d57937deef85819c396 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 16 Sep 2026 12:10:25 +0200 Subject: music: the playlist menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/meshbay-hub/src/meshbay_hub/static/app.js | 15 + .../src/meshbay_hub/static/locales/de.js | 22 ++ .../src/meshbay_hub/static/locales/en.js | 22 ++ .../src/meshbay_hub/static/locales/es.js | 22 ++ .../src/meshbay_hub/static/locales/fr.js | 22 ++ .../src/meshbay_hub/static/locales/it.js | 22 ++ .../src/meshbay_hub/static/locales/ja.js | 22 ++ .../src/meshbay_hub/static/locales/nl.js | 22 ++ .../src/meshbay_hub/static/locales/pl.js | 22 ++ .../src/meshbay_hub/static/locales/pt-BR.js | 22 ++ .../src/meshbay_hub/static/locales/zh-CN.js | 22 ++ .../meshbay-hub/src/meshbay_hub/static/menu.js | 41 ++- .../src/meshbay_hub/static/music-app.js | 79 ++++- .../src/meshbay_hub/static/music-player.js | 103 +++++- .../src/meshbay_hub/static/playlist-menu.js | 206 ++++++++++++ .../src/meshbay_hub/static/search-page.js | 2 +- .../meshbay-hub/src/meshbay_hub/static/style.css | 51 +++ .../meshbay-hub/tests/harness/music_queue_probe.py | 58 +++- .../meshbay-hub/tests/harness/playlist_ui_probe.py | 368 +++++++++++++++++++++ packages/meshbay-hub/tests/test_hook_ordering.py | 2 +- packages/meshbay-hub/tests/test_music_queue.py | 27 +- packages/meshbay-hub/tests/test_playlist_ui.py | 108 ++++++ .../meshbay-hub/tests/test_transport_contracts.py | 2 +- 23 files changed, 1248 insertions(+), 34 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js create mode 100644 packages/meshbay-hub/tests/harness/playlist_ui_probe.py create mode 100644 packages/meshbay-hub/tests/test_playlist_ui.py (limited to 'packages') 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() { ${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`
`; - 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` - ${open && (it.items.length - ? html`<${MenuItems} items=${it.items} depth=${depth + 1} onClose=${onClose} />` - : html`
${it.empty || ''}
`)} + ${open && (pending + ? html`
` + : (rows && rows.length + ? html`<${MenuItems} items=${rows} depth=${depth + 1} onClose=${onClose} />` + : html`
${it.empty || ''}
`))} `; } 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')} + ${userId && html`<${PlaylistMenuButton} userId=${userId} + lists=${playlists} reload=${reloadPlaylists} + onPlayQueue=${onPlayQueue} onSync=${syncNow} />`} <${Pager} pager=${pager} /> ${!hideFilter && html`