aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-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]