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