diff options
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] |