aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 12:10:25 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 12:10:25 +0200
commit9cbff21274604e37c0986d57937deef85819c396 (patch)
treebdfb55978bb105fd78d1d35db33ff8bb35457f67 /packages/meshbay-hub
parent2e973795383b71f63ae9e3bef0e5dfc7930b4c90 (diff)
downloadmeshbay-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')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/menu.js41
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js79
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-player.js103
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js206
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css51
-rwxr-xr-xpackages/meshbay-hub/tests/harness/music_queue_probe.py58
-rw-r--r--packages/meshbay-hub/tests/harness/playlist_ui_probe.py368
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py2
-rw-r--r--packages/meshbay-hub/tests/test_music_queue.py27
-rw-r--r--packages/meshbay-hub/tests/test_playlist_ui.py108
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py2
23 files changed, 1248 insertions, 34 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;
+}
diff --git a/packages/meshbay-hub/tests/harness/music_queue_probe.py b/packages/meshbay-hub/tests/harness/music_queue_probe.py
index f74ceb2..a9146ce 100755
--- a/packages/meshbay-hub/tests/harness/music_queue_probe.py
+++ b/packages/meshbay-hub/tests/harness/music_queue_probe.py
@@ -119,12 +119,24 @@ function Harness() {
const onPlayQueue = useCallback((tracks, startIndex, source, op) => {
setQueue({ tracks, startIndex, nonce: Date.now(), op: op || 'replace' });
}, []);
+ // A queue that crosses groups cannot be built from one group's page, and
+ // that is the case the skipping rule exists for — so the probe reaches in
+ // for that one step rather than pretending a gesture builds it.
+ window.__probePlayQueue = onPlayQueue;
+
+ // Rejects at once for a group nothing serves, and hangs for every other —
+ // hanging is what the stub node does anyway, and what matters here is which
+ // track the playhead lands on, not whether anything plays.
+ const getConnection = (groupId) => (String(groupId).startsWith('dead')
+ ? Promise.reject(new Error('no node'))
+ : new Promise(() => {}));
+
return html`
<${GroupPage} groupId="g1" token="t" username="me" userId="u1"
group=${{ id: 'g1', name: 'un groupe', owner_username: 'me', is_admin: false }}
userPrefs=${{ default_tab: 'music', media_page_size: '50' }}
onPlayQueue=${onPlayQueue} />
- ${queue && html`<${MusicPlayerBar} getConnection=${() => new Promise(() => {})}
+ ${queue && html`<${MusicPlayerBar} getConnection=${getConnection}
queue=${queue} userPrefs=${{}} onClose=${() => setQueue(null)} />`}
`;
}
@@ -171,9 +183,29 @@ const clickMenu = async (i) => {
await initLocale();
render(html`<${Harness} />`, document.getElementById('root'));
- if (!await waitFor('.music-card')) return fail('no album grid');
+ // Wait for the grid to be *complete*, not merely present — and scroll,
+ // because it will not complete otherwise: the tiles mount on intersection
+ // (`LazyTile`), so an album below the fold has no card at all until the
+ // page is scrolled to it. A probe that counts what happens to be on screen
+ // is measuring the height of its own iframe; adding one button to the
+ // toolbar was enough to push the fourth album out of view.
+ const waitForCount = async (sel, n, tries = 80) => {
+ for (let i = 0; i < tries; i++) {
+ if (document.querySelectorAll(sel).length >= n) {
+ scrollTo(0, 0);
+ await sleep(100);
+ return true;
+ }
+ scrollTo(0, document.documentElement.scrollHeight);
+ await sleep(50);
+ }
+ return false;
+ };
+ if (!await waitForCount('.music-card', 4)) {
+ return fail('expected 4 albums, got '
+ + document.querySelectorAll('.music-card').length);
+ }
const cards = [...document.querySelectorAll('.music-card')];
- if (cards.length < 4) return fail('expected 4 albums, got ' + cards.length);
// 1. Open album 1 and play its second track — the ordinary path, through a
// row that is no longer one big button.
@@ -219,6 +251,26 @@ const clickMenu = async (i) => {
await sleep(250);
await queueNow('replace with album 4');
+ // 6. A group that does not answer: every one of its queued tracks is
+ // skipped in one step, not one failure at a time against a bound that
+ // was sized for corrupt files.
+ const mixed = (id, group) => ({
+ id, name: id + '.flac', display_title: id, path: 'p', size: 10,
+ type: 'audio', duration: 100, groupId: group,
+ });
+ window.__probePlayQueue([
+ mixed('live-1', 'g1'),
+ mixed('dead-1', 'dead-a'), mixed('dead-2', 'dead-a'),
+ mixed('dead-3', 'dead-a'), mixed('dead-4', 'dead-a'),
+ mixed('dead-5', 'dead-a'), mixed('dead-6', 'dead-a'),
+ mixed('live-2', 'g1'),
+ ], 1, null, 'replace');
+ await sleep(1200);
+ steps.push({
+ step: 'an unreachable group is skipped whole',
+ nowPlaying: (document.querySelector('.music-player-title') || {}).textContent || null,
+ });
+
parent.postMessage({ steps, logs: LOGS.slice(0, 8) }, '*');
} catch (err) {
fail(String((err && err.stack) || err));
diff --git a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py
new file mode 100644
index 0000000..f0fc9c3
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py
@@ -0,0 +1,368 @@
+#!/usr/bin/env python3
+"""
+The playlist menus, pressed in a real browser.
+
+`playlists.js` is covered against a stubbed node by `playlist_store_probe.py`,
+and the merge and the sealing by their own tests. None of that reaches the part
+a person actually touches: whether the toolbar button opens a menu, whether
+naming a playlist in a field works (Electron has no `prompt` — it throws), and
+whether "add to playlist" on an album cover puts the right tracks in the right
+playlist.
+
+So this renders the shipped `GroupPage`, `MusicPlayerBar` and playlist menus,
+presses the real controls, and reads the result back out of the store.
+
+ playlist_ui_probe.py
+
+Prints JSON: one entry per step.
+"""
+
+import http.server
+import json
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
+PORT = 8755
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+FRAME = r"""<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head><body>
+<nav class="nav"><div class="nav-left"><a class="nav-brand" href="#/">MeshBay</a></div></nav>
+<div class="layout"><main class="main"><div id="root"></div></main></div>
+<script>
+// Four albums of three tracks, each track titled so the queue can be read back
+// unambiguously: "A2-t3" is the third track of the second album and nothing
+// else. A fixture whose rows cannot be told apart measures nothing.
+const ENTRIES = [];
+let n = 0;
+for (let a = 1; a <= 4; a++) {
+ for (let i = 1; i <= 3; i++) {
+ ENTRIES.push({
+ id: 'e' + (++n), name: `A${a}-t${i}.flac`, display_title: `A${a}-t${i}`,
+ path: `musique/Artiste ${a}/Album ${a}`, type: 'audio',
+ artist: `Artiste ${a}`, album: `Album ${a}`,
+ track_no: i, duration: 200, size: 1024 * 1024, added_at: 1750000000 + n,
+ });
+ }
+}
+
+const ACK = {
+ is_node_admin: false,
+ enabled_apps: ['files', 'music'],
+ tmdb_enabled: false, musicbrainz_enabled: false,
+ video_directories: [], music_directories: ['musique'], photo_directories: [],
+};
+
+window.MeshBayTransport = function () {
+ const self = {
+ connected: false, memberRole: 'member', supportsAppOps: true,
+ sessionKeys: null, gekRaw: null,
+ newNodeBundle: null, newNodeBundleRecovery: null,
+ async connect() { self.connected = true; return ACK; },
+ async fetchIndex() {
+ return { entries: ENTRIES, dirs: ['musique'],
+ roots: [{ name: 'musique', available: true, writable: false,
+ removable: false }] };
+ },
+ async fetchChatHistory() { return { messages: [], hasMore: false }; },
+ async fetchLinkPreview() { return { ok: false }; },
+ close() {},
+ };
+ return new Proxy(self, {
+ get(target, prop) {
+ if (prop in target) return target[prop];
+ if (typeof prop === 'string' && prop.startsWith('on')) return undefined;
+ if (typeof prop === 'symbol') return undefined;
+ return () => new Promise(() => {});
+ },
+ set(target, prop, value) { target[prop] = value; return true; },
+ });
+};
+</script>
+<script type="module">
+import { html, render, useState, useCallback } from '/vendor/htm-preact.js';
+import { initLocale } from '/i18n.js';
+import { GroupPage } from '/group-page.js';
+import { MusicPlayerBar } from '/music-player.js';
+import { session } from '/hub-client.js';
+import { t } from '/i18n.js';
+import * as P from '/playlists.js';
+
+const LOGS = [];
+addEventListener('error', (e) => LOGS.push('error: ' + (e.message || e)));
+addEventListener('unhandledrejection',
+ (e) => LOGS.push('rejection: ' + ((e.reason && (e.reason.stack || e.reason.message)) || e.reason)));
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const waitFor = async (sel, tries = 60) => {
+ for (let i = 0; i < tries; i++) {
+ const el = document.querySelector(sel);
+ if (el) return el;
+ await sleep(50);
+ }
+ return null;
+};
+
+// The shell's two jobs, and only those: hold the queue, and pass `op` through.
+// app.js does more (a transport fast path, the stop button); the contract this
+// exercises is the shape of what it hands the player.
+function Harness() {
+ const [queue, setQueue] = useState(null);
+ const onPlayQueue = useCallback((tracks, startIndex, source, op) => {
+ setQueue({ tracks, startIndex, nonce: Date.now(), op: op || 'replace' });
+ }, []);
+ return html`
+ <${GroupPage} groupId="g1" token="t" username="me" userId="u1"
+ group=${{ id: 'g1', name: 'un groupe', owner_username: 'me', is_admin: false }}
+ userPrefs=${{ default_tab: 'music', media_page_size: '50' }}
+ onPlayQueue=${onPlayQueue} />
+ ${queue && html`<${MusicPlayerBar} getConnection=${() => new Promise(() => {})}
+ queue=${queue} userPrefs=${{}} onClose=${() => setQueue(null)} />`}
+ `;
+}
+
+const steps = [];
+
+// The queue as a person sees it: the player's own panel, opened and closed.
+async function queueNow(label, extra) {
+ const open = document.querySelector('.music-player-extra .music-player-btn');
+ if (!open) { steps.push({ step: label, bar: false, ...extra }); return; }
+ open.click();
+ const panel = await waitFor('.music-detail .music-tracklist');
+ const rows = [...document.querySelectorAll('.music-detail .music-tracklist .music-track-row')];
+ steps.push({
+ step: label,
+ bar: true,
+ play: rows.map((r) => r.querySelector('.music-track-title').textContent),
+ playing: (rows.findIndex((r) => r.classList.contains('active'))),
+ nowPlaying: (document.querySelector('.music-player-title') || {}).textContent || null,
+ ...extra,
+ });
+ const close = document.querySelector('.music-detail .video-close');
+ if (close) close.click();
+ await sleep(120);
+}
+
+const rightClick = (el) => el.dispatchEvent(new MouseEvent('contextmenu', {
+ bubbles: true, cancelable: true, clientX: 200, clientY: 200 }));
+
+const menuLabels = () => [...document.querySelectorAll('.ctx-menu .ctx-menu-item')]
+ .map((b) => b.querySelector('.ctx-menu-label').textContent);
+
+const clickMenu = async (i) => {
+ const items = [...document.querySelectorAll('.ctx-menu .ctx-menu-item')];
+ items[i].click();
+ await sleep(200);
+};
+
+(async () => {
+ const fail = (why) => parent.postMessage(
+ { error: why, logs: LOGS.slice(0, 12),
+ text: (document.getElementById('root').textContent || '').slice(0, 400) }, '*');
+ try {
+ await initLocale();
+
+ // A real HKDF handle, so the store derives its key the way it really does.
+ const raw = new Uint8Array(32).fill(3);
+ session.bundleKey = {
+ v2: await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false,
+ ['encrypt', 'decrypt']),
+ v2hkdf: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']),
+ };
+ // Deleting a playlist asks. A modal dialog blocks the page and would wedge
+ // the probe, so the answer is stubbed rather than the question avoided —
+ // `confirm` is what the application really calls.
+ let asked = null;
+ window.confirm = (q) => { asked = q; return true; };
+
+ render(html`<${Harness} />`, document.getElementById('root'));
+
+ const waitForCount = async (sel, n, tries = 80) => {
+ for (let i = 0; i < tries; i++) {
+ if (document.querySelectorAll(sel).length >= n) {
+ scrollTo(0, 0); await sleep(100); return true;
+ }
+ scrollTo(0, document.documentElement.scrollHeight);
+ await sleep(50);
+ }
+ return false;
+ };
+ if (!await waitForCount('.music-card', 4)) return fail('grid never completed');
+
+ const toolbarButton = [...document.querySelectorAll('.video-toolbar .tb-btn')]
+ .find((b) => (b.getAttribute('title') || '').length
+ && b.querySelector('svg')
+ && !b.classList.contains('active'));
+ if (!toolbarButton) return fail('no playlist button in the toolbar');
+
+ const menuLabels = () => [...document.querySelectorAll('.ctx-menu .ctx-menu-item')]
+ .map((b) => b.querySelector('.ctx-menu-label').textContent);
+ const clickLabel = async (text) => {
+ const item = [...document.querySelectorAll('.ctx-menu .ctx-menu-item')]
+ .find((b) => b.querySelector('.ctx-menu-label').textContent === text);
+ if (!item) throw new Error('no menu item "' + text + '" among ' + menuLabels());
+ item.click();
+ await sleep(250);
+ };
+ const openToolbar = async () => { toolbarButton.click(); await sleep(200); };
+
+ // 1. The toolbar menu, in the order it promises.
+ await openToolbar();
+ steps.push({ step: 'toolbar menu', items: menuLabels() });
+
+ // 2. Create a playlist by typing a name into a field.
+ await clickLabel(t('playlists.create'));
+ const field = await waitFor('.playlist-modal input');
+ if (!field) return fail('no name field');
+ field.value = 'Soirée';
+ field.dispatchEvent(new Event('input', { bubbles: true }));
+ await sleep(80);
+ document.querySelector('.playlist-modal .admin-btn').click();
+ await sleep(350);
+ steps.push({ step: 'created', modalGone: !document.querySelector('.playlist-modal'),
+ lists: await P.listPlaylists('u1') });
+
+ // 3. Add an album to it from the cover's own menu.
+ const dots = document.querySelectorAll('.music-card .ctx-dots');
+ dots[1].click();
+ await sleep(200);
+ steps.push({ step: 'cover menu', items: menuLabels() });
+ await clickLabel(t('playlists.add_to'));
+ steps.push({ step: 'add-to submenu', items: menuLabels() });
+ await clickLabel('Soirée');
+ await sleep(300);
+ const lists = await P.listPlaylists('u1');
+ steps.push({ step: 'added to the playlist',
+ lists: lists.map((p) => ({ name: p.name, count: p.count })),
+ note: (document.querySelector('.playlist-note') || {}).textContent || null });
+
+ // 4. Load it into the queue.
+ await openToolbar();
+ await clickLabel(t('playlists.load'));
+ await clickLabel('Soirée');
+ await sleep(400);
+ const open = document.querySelector('.music-player-extra .music-player-btn');
+ if (!open) return fail('nothing is playing after loading a playlist');
+ open.click();
+ await waitFor('.music-detail .music-tracklist');
+ steps.push({ step: 'loaded into the queue',
+ play: [...document.querySelectorAll('.music-detail .music-tracklist .music-track-title')]
+ .map((e) => e.textContent) });
+ document.querySelector('.music-detail .video-close').click();
+ await sleep(150);
+
+ // 5. Remove one track, through the two-level submenu.
+ await openToolbar();
+ await clickLabel(t('playlists.remove_track'));
+ await clickLabel('Soirée');
+ await sleep(350);
+ steps.push({ step: 'the tracklist submenu', items: menuLabels() });
+ await clickLabel('A2-t2');
+ await sleep(300);
+ steps.push({ step: 'track removed',
+ lists: (await P.listPlaylists('u1')).map((p) => ({ name: p.name, count: p.count })),
+ tracks: (await P.getPlaylistTracks('u1', lists.find((p) => p.name === 'Soirée').id))
+ .map((tr) => tr.display_title) });
+
+ // 6. Delete it.
+ await openToolbar();
+ await clickLabel(t('playlists.delete'));
+ await clickLabel('Soirée');
+ await sleep(300);
+ steps.push({ step: 'deleted', asked: !!asked,
+ lists: (await P.listPlaylists('u1')).map((p) => p.name) });
+
+ parent.postMessage({ steps, logs: LOGS.slice(0, 8) }, '*');
+ } catch (err) {
+ fail(String((err && err.stack) || err));
+ }
+})();
+</script></body></html>"""
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head>
+<body style="margin:0"><div id="frames"></div><script>
+addEventListener('message', (e) => {
+ fetch('/log', { method: 'POST', body: JSON.stringify(e.data) });
+});
+const f = document.createElement('iframe');
+f.src = '/case';
+f.style.cssText = 'width:1100px;height:800px;border:0;display:block';
+document.getElementById('frames').appendChild(f);
+</script></body></html>"""
+
+
+class H(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length") or 0)
+ if self.path == "/log":
+ RECORDS.append(json.loads(self.rfile.read(length).decode()))
+ else:
+ self.rfile.read(length)
+ self.send_response(204)
+ self.end_headers()
+
+ def _send(self, body: bytes, ctype: str) -> None:
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ path = self.path.split("?")[0]
+ if path == "/":
+ self._send(PAGE.encode(), "text/html; charset=utf-8")
+ elif path == "/case":
+ self._send(FRAME.encode(), "text/html; charset=utf-8")
+ elif path == "/v1/groups/g1/nodes":
+ self._send(b'{"nodes": [{"node_id": "n1"}]}', "application/json")
+ else:
+ asset = (STATIC / path.lstrip("/")).resolve()
+ if not str(asset).startswith(str(STATIC)) or not asset.is_file():
+ self.send_response(404)
+ self.end_headers()
+ return
+ self._send(asset.read_bytes(),
+ "text/css" if asset.suffix == ".css"
+ else "text/javascript" if asset.suffix == ".js"
+ else "application/octet-stream")
+
+
+def main() -> int:
+ with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile:
+ proc = subprocess.Popen(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}", "--window-size=1100,900",
+ f"http://127.0.0.1:{PORT}/"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ for _ in range(400):
+ if RECORDS:
+ break
+ time.sleep(0.1)
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ if not RECORDS:
+ print(json.dumps({"error": "no measurement"}), file=sys.stderr)
+ return 1
+ print(json.dumps(RECORDS[0], indent=1))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py
index 994ca2e..78561b6 100644
--- a/packages/meshbay-hub/tests/test_hook_ordering.py
+++ b/packages/meshbay-hub/tests/test_hook_ordering.py
@@ -51,7 +51,7 @@ STATIC_FILES = [
"search-page.js",
# The shared pop-up menu (docs/playlists.md §10.1), reached from the media
# views rather than imported by the shell.
- "menu.js",
+ "menu.js", "playlist-menu.js",
]
pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable")
diff --git a/packages/meshbay-hub/tests/test_music_queue.py b/packages/meshbay-hub/tests/test_music_queue.py
index a53047d..5bf9e97 100644
--- a/packages/meshbay-hub/tests/test_music_queue.py
+++ b/packages/meshbay-hub/tests/test_music_queue.py
@@ -52,11 +52,12 @@ def test_playing_a_track_queues_its_album(steps):
assert s["nowPlaying"] == "A1-t2"
-def test_right_click_opens_the_three_queue_verbs(steps):
- """Three items, in the order the menu promises. Their labels are whatever
- the browser's locale renders, so this counts them rather than reading them
- — the point is that the menu opened and is not empty."""
- assert len(steps["append album 2"]["menu"]) == 3
+def test_right_click_opens_the_queue_verbs(steps):
+ """The menu opened and carries the three queue verbs, plus "add to
+ playlist" — which is why this counts four rather than three. Labels are
+ whatever the browser's locale renders, so counting is what this can assert;
+ `test_playlist_ui.py` is what checks the fourth one leads anywhere."""
+ assert len(steps["append album 2"]["menu"]) == 4
def test_add_to_queue_appends_and_leaves_the_playhead_alone(steps):
@@ -96,6 +97,22 @@ def test_play_all_replaces_everything(steps):
assert s["playing"] == 0
+def test_an_unreachable_group_is_skipped_whole_rather_than_one_track_at_a_time(steps):
+ """A regression playlists would otherwise introduce into correct code.
+
+ `MAX_CONSECUTIVE_FAILURES` is 5 and was sized for a corrupt file between
+ two good ones. A playlist whose next six tracks all come from one node that
+ is off hits that bound and stops on the sixth, with an error, and the
+ reader reads it as "the playlist is broken".
+
+ A connection failure is a property of the *group*, not of each of its
+ tracks in turn, so the group is marked down and all of its queued tracks go
+ in one step. Six dead tracks here, one more than the bound: without the
+ split this lands on the last of them instead of past them.
+ """
+ assert steps["an unreachable group is skipped whole"]["nowPlaying"] == "live-2"
+
+
# ── the wrappers, read rather than driven ────────────────────────────────────
#
# The probe proves the chain works for the group page. Search mounts the same
diff --git a/packages/meshbay-hub/tests/test_playlist_ui.py b/packages/meshbay-hub/tests/test_playlist_ui.py
new file mode 100644
index 0000000..c449dc7
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_playlist_ui.py
@@ -0,0 +1,108 @@
+"""
+The playlist menus, pressed in a real browser.
+
+The store is covered against a stubbed node, and the merge and the sealing by
+their own tests. None of that reaches the part a person touches: whether the
+toolbar button opens a menu at all, whether naming a playlist works (Electron
+has no `prompt` — it *throws*, which is how the Files toolbar's New folder
+button came to do nothing), and whether "add to playlist" on a cover puts the
+right tracks in the right playlist.
+
+The probe renders the shipped `GroupPage`, `MusicPlayerBar` and playlist menus
+and presses the real controls. Labels come back in whatever locale the browser
+picked, so what is asserted is shape and behaviour — counts, order, and what
+ended up in the store — rather than English strings.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "playlist_ui_probe.py"
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("google-chrome") is None or not (STATIC / "playlist-menu.js").exists(),
+ reason="Chrome or the SPA sources are not available")
+
+
+@pytest.fixture(scope="module")
+def steps():
+ proc = subprocess.run(["python3", str(HARNESS)],
+ capture_output=True, text=True, timeout=300)
+ assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
+ out = json.loads(proc.stdout)
+ assert "error" not in out, out
+ assert not out.get("logs"), f"the page logged: {out['logs']}"
+ return {s["step"]: s for s in out["steps"]}
+
+
+def test_one_button_opens_the_five_playlist_verbs_in_order(steps):
+ """Load, create, delete, remove a track — the order asked for — and Sync
+ now, which has to live somewhere and this is the only playlist surface."""
+ assert len(steps["toolbar menu"]["items"]) == 5
+
+
+def test_a_playlist_is_named_in_a_field_and_not_a_prompt(steps):
+ """`window.prompt` throws in Electron and does not return null, so it is
+ banned outright (`test_no_prompt_in_the_spa.py`). Anything that needs typed
+ input needs a field, and this is the one that does."""
+ s = steps["created"]
+ assert s["modalGone"] is True, "the modal stayed open, so nothing was saved"
+ assert [p["name"] for p in s["lists"]] == ["Soirée"]
+ assert s["lists"][0]["count"] == 0
+
+
+def test_the_cover_menu_carries_the_queue_verbs_and_add_to_playlist(steps):
+ assert len(steps["cover menu"]["items"]) == 4
+
+
+def test_favourites_is_offered_first_before_it_has_ever_been_used(steps):
+ """The reserved playlist is materialised on first use, so the submenu has
+ it on a fresh account — and has it first, as the design promises."""
+ items = steps["add-to submenu"]["items"]
+ submenu = items[4:]
+ assert len(submenu) == 3, submenu
+ assert submenu[1] == "Soirée"
+
+
+def test_adding_an_album_from_its_cover_puts_its_tracks_in_the_playlist(steps):
+ s = steps["added to the playlist"]
+ assert s["lists"] == [{"name": "Soirée", "count": 3}]
+ assert s["note"], "nothing said it had happened"
+
+
+def test_loading_a_playlist_replaces_the_queue_with_its_tracks(steps):
+ """Below `onPlayQueue` a playlist and an album are indistinguishable, which
+ is why auto-advance, shuffle and prefetch are unchanged by construction."""
+ assert steps["loaded into the queue"]["play"] == ["A2-t1", "A2-t2", "A2-t3"]
+
+
+def test_the_tracklist_submenu_is_two_levels_and_fetched_when_expanded(steps):
+ """As asked: the playlist, then its tracks. The second level is read from
+ IndexedDB when it is opened — building it eagerly would read every
+ playlist's tracks to draw a menu nobody may open."""
+ items = steps["the tracklist submenu"]["items"]
+ assert "A2-t1" in items and "A2-t3" in items
+ assert items.index("A2-t1") > 0
+ # The tracks sit under their playlist, between it and the next top-level
+ # item — expanded in place rather than in a flyout.
+ assert items[-1] == steps["toolbar menu"]["items"][-1]
+
+
+def test_removing_a_track_removes_that_one(steps):
+ s = steps["track removed"]
+ assert s["lists"] == [{"name": "Soirée", "count": 2}]
+ assert s["tracks"] == ["A2-t1", "A2-t3"], "the wrong track was removed"
+
+
+def test_deleting_a_playlist_asks_first(steps):
+ """A deletion is a tombstone: there is nothing in the interface that undoes
+ it. `confirm` and not a component — Electron implements it and a dozen
+ places in this SPA already use it."""
+ s = steps["deleted"]
+ assert s["asked"] is True, "a playlist was deleted without asking"
+ assert s["lists"] == []
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index 5c535a3..9c3a88b 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -43,7 +43,7 @@ SPLIT_FILES = [APP, GROUP_PAGE, CHAT_APP, STATIC / "files-app.js",
STATIC / "photos-app-settings.js",
STATIC / "helloworld-app.js",
STATIC / "helloworld-app-settings.js",
- STATIC / "menu.js",
+ STATIC / "menu.js", STATIC / "playlist-menu.js",
STATIC / "auth-page.js", STATIC / "explore-page.js",
CREATE_GROUP]