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