aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 11:01:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 11:01:02 +0200
commit20706fb9a4ec646816b44a10842aa8f58ea0fd75 (patch)
tree126be97d794491196c6033ae76d4680f24188595 /packages
parenta79a38a22a6145c475f50eeadb79b451aee31c11 (diff)
downloadmeshbay-20706fb9a4ec646816b44a10842aa8f58ea0fd75.tar.gz
music: play, play next, add to queue
The player's queue could only be replaced: every onPlayQueue reset tracks/order/pos together. It becomes one reducer (queue-ops.js) with an `op`, because two appends batched into one tick cannot both read the track count out of separate useStates. A shared pop-up menu (menu.js) carries the three verbs, on right-click and on a dots button. A track row is now a div holding two buttons: a button cannot contain a button. Found by the browser probe: both music wrappers took two arguments and forwarded two, so every "add to queue" arrived as a plain play. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js30
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/icon.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/menu.js171
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js135
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-player.js92
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/queue-ops.js147
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css141
-rwxr-xr-xpackages/meshbay-hub/tests/harness/music_queue_probe.py308
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py3
-rw-r--r--packages/meshbay-hub/tests/test_music_queue.py115
-rw-r--r--packages/meshbay-hub/tests/test_queue_ops.py319
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py1
24 files changed, 1431 insertions, 97 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index a249ccf..ef8d1e9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -725,17 +725,27 @@ function App() {
return { transport: conn.transport, gek: conn.gek };
}, []);
- const handlePlayQueue = useCallback((tracks, startIndex, source) => {
- if (source && source.transportRef) {
- groupTransportRef.current = {
- groupId: source.groupId,
- transportRef: source.transportRef,
- gekRef: source.gekRef,
- };
- } else {
- groupTransportRef.current = null;
+ // `op` is 'replace' (the default, and what playing an album or loading a
+ // playlist means), 'next', or 'append' — docs/playlists.md §9.2.
+ const handlePlayQueue = useCallback((tracks, startIndex, source, op) => {
+ const how = op || 'replace';
+ // A single-slot fast path for the group whose page is open, so playing
+ // from it reuses the live transport instead of dialing through the pool.
+ // Only a replace is about that group: repointing it because one track
+ // from somewhere else was enqueued would drop whatever is *playing* back
+ // to the pool, for nothing.
+ if (how === 'replace') {
+ if (source && source.transportRef) {
+ groupTransportRef.current = {
+ groupId: source.groupId,
+ transportRef: source.transportRef,
+ gekRef: source.gekRef,
+ };
+ } else {
+ groupTransportRef.current = null;
+ }
}
- setMusicQueue({ tracks, startIndex, nonce: Date.now() });
+ setMusicQueue({ tracks, startIndex, nonce: Date.now(), op: how });
}, []);
const handleStopMusic = useCallback(() => setMusicQueue(null), []);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 4de33b9..060fd6e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -173,11 +173,18 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const [searchListed, setSearchListed] = useState(true);
// MusicBrainz on/off (per-group) — docs/musicbay.md §3.2.
const [musicbrainzConfig, setMusicbrainzConfig] = useState(null);
- const onPlayQueue = useCallback((tracks, startIndex) => {
- setVideoEntry(null);
+ // `op` rides through to the shell — 'replace', 'next' or 'append'
+ // (docs/playlists.md §9.2). It has to be named here: a wrapper that takes
+ // two arguments and forwards two silently turns every "add to queue" in this
+ // group into a "play", and nothing about that reads as wrong at the call
+ // site or here.
+ const onPlayQueue = useCallback((tracks, startIndex, op) => {
+ // Only a replace changes what is on screen; enqueueing something does not
+ // close whatever the reader was already looking at.
+ if (!op || op === 'replace') setVideoEntry(null);
if (parentOnPlayQueue) {
const annotated = tracks.map(tr => tr.groupId ? tr : { ...tr, groupId });
- parentOnPlayQueue(annotated, startIndex, { transportRef, gekRef, groupId });
+ parentOnPlayQueue(annotated, startIndex, { transportRef, gekRef, groupId }, op);
}
}, [parentOnPlayQueue, groupId]);
// Paired ≠ operator account. `is_node_admin` says the hub account owning this
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
index f45d339..ad9fb82 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
@@ -100,6 +100,12 @@ const ICON_PATHS = {
'M8 11h6'],
frame: ['M4 9V5a1 1 0 0 1 1-1h4', 'M15 4h4a1 1 0 0 1 1 1v4',
'M20 15v4a1 1 0 0 1-1 1h-4', 'M9 20H5a1 1 0 0 1-1-1v-4'],
+ // A list with a plus: the playlist verbs, and the one button Music's
+ // toolbar can spare for them (docs/playlists.md §10.3).
+ playlist: ['M4 7h12', 'M4 12h12', 'M4 17h7', 'M17.5 14.5v6', 'M14.5 17.5h6'],
+ // A list with a play head at its foot — "after this one", as distinct from
+ // `play` (start now) and `plus` (at the end).
+ playnext: ['M4 7h10', 'M4 12h10', 'M4 17h6', 'M15.5 13.5v7l5.5-3.5z'],
};
// The M of the wordmark is a picture; the rest is text. Resolved from this
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 19004ae..4cb3e1b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -296,6 +296,10 @@ export default {
'music.unknown_album': 'Unbekanntes Album',
'music.various': 'Verschiedenes',
'music.play_all': 'Alle abspielen',
+ 'music.menu_more': 'Mehr…',
+ 'music.menu_play': 'Abspielen',
+ 'music.menu_play_next': 'Als Nächstes',
+ 'music.menu_enqueue': 'Zur Warteschlange',
'music.n_tracks': {
one: '{n} Titel',
other: '{n} Titel',
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 8084cd0..cc94d87 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -294,6 +294,10 @@ export default {
'music.unknown_album': 'Unknown album',
'music.various': 'Various',
'music.play_all': 'Play all',
+ 'music.menu_more': 'More…',
+ 'music.menu_play': 'Play',
+ 'music.menu_play_next': 'Play next',
+ 'music.menu_enqueue': 'Add to queue',
'music.n_tracks': {
one: '{n} track',
other: '{n} tracks',
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 589f49d..8ce9d45 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -294,6 +294,10 @@ export default {
'music.unknown_album': 'Álbum desconocido',
'music.various': 'Varios',
'music.play_all': 'Reproducir todo',
+ 'music.menu_more': 'Más…',
+ 'music.menu_play': 'Reproducir',
+ 'music.menu_play_next': 'Reproducir a continuación',
+ 'music.menu_enqueue': 'Añadir a la cola',
'music.n_tracks': {
one: '{n} pista',
other: '{n} pistas',
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 8409c6d..2caf912 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -295,6 +295,10 @@ export default {
'music.unknown_album': 'Album inconnu',
'music.various': 'Divers',
'music.play_all': 'Tout lire',
+ 'music.menu_more': 'Plus…',
+ 'music.menu_play': 'Lire',
+ 'music.menu_play_next': 'Lire ensuite',
+ 'music.menu_enqueue': 'Ajouter à la file',
'music.n_tracks': {
one: '{n} piste',
other: '{n} pistes',
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 c501639..5edfb24 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -295,6 +295,10 @@ export default {
'music.unknown_album': 'Album sconosciuto',
'music.various': 'Vari',
'music.play_all': 'Riproduci tutto',
+ 'music.menu_more': 'Altro…',
+ 'music.menu_play': 'Riproduci',
+ 'music.menu_play_next': 'Riproduci dopo',
+ 'music.menu_enqueue': 'Aggiungi alla coda',
'music.n_tracks': {
one: '{n} traccia',
other: '{n} tracce',
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 85b9f31..29fd626 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -292,6 +292,10 @@ export default {
'music.unknown_album': '不明なアルバム',
'music.various': 'その他',
'music.play_all': 'すべて再生',
+ 'music.menu_more': 'その他…',
+ 'music.menu_play': '再生',
+ 'music.menu_play_next': '次に再生',
+ 'music.menu_enqueue': 'キューに追加',
'music.n_tracks': {
one: '{n}曲',
other: '{n}曲',
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 68e3a7d..f4605b3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -296,6 +296,10 @@ export default {
'music.unknown_album': 'Onbekend album',
'music.various': 'Diversen',
'music.play_all': 'Alles afspelen',
+ 'music.menu_more': 'Meer…',
+ 'music.menu_play': 'Afspelen',
+ 'music.menu_play_next': 'Hierna afspelen',
+ 'music.menu_enqueue': 'Aan wachtrij toevoegen',
'music.n_tracks': {
one: '{n} nummer',
other: '{n} nummers',
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 f7db14e..3b65ff9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -303,6 +303,10 @@ export default {
'music.unknown_album': 'Nieznany album',
'music.various': 'Różne',
'music.play_all': 'Odtwórz wszystko',
+ 'music.menu_more': 'Więcej…',
+ 'music.menu_play': 'Odtwórz',
+ 'music.menu_play_next': 'Odtwórz jako następne',
+ 'music.menu_enqueue': 'Dodaj do kolejki',
'music.n_tracks': {
one: '{n} utwór',
few: '{n} utwory',
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 3a6f55f..7ed921c 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
@@ -296,6 +296,10 @@ export default {
'music.unknown_album': 'Álbum desconhecido',
'music.various': 'Diversos',
'music.play_all': 'Reproduzir tudo',
+ 'music.menu_more': 'Mais…',
+ 'music.menu_play': 'Reproduzir',
+ 'music.menu_play_next': 'Reproduzir em seguida',
+ 'music.menu_enqueue': 'Adicionar à fila',
'music.n_tracks': {
one: '{n} faixa',
other: '{n} faixas',
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 cb84105..280b3aa 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
@@ -289,6 +289,10 @@ export default {
'music.unknown_album': '未知专辑',
'music.various': '其他',
'music.play_all': '全部播放',
+ 'music.menu_more': '更多…',
+ 'music.menu_play': '播放',
+ 'music.menu_play_next': '下一首播放',
+ 'music.menu_enqueue': '加入队列',
'music.n_tracks': {
one: '{n} 首曲目',
other: '{n} 首曲目',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/menu.js b/packages/meshbay-hub/src/meshbay_hub/static/menu.js
new file mode 100644
index 0000000..4aa08ca
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/menu.js
@@ -0,0 +1,171 @@
+import {
+ html, useState, useEffect, useRef, useCallback,
+} from './vendor/htm-preact.js';
+import { Icon } from './icon.js';
+
+/**
+ * A pop-up menu, opened at a point — the shared component the media views use
+ * for "what do you want to do with this album" (docs/playlists.md §10.1).
+ *
+ * Two affordances, both always present, because neither covers everyone:
+ * right-click is what a desktop reader reaches for and leaves no chrome on a
+ * grid of covers, and a dots button is the only thing that exists on a
+ * touchscreen. `openAt` takes either event and needs to know which.
+ *
+ * **Submenus expand in place** rather than flying out sideways. The account
+ * menu's language list already does this (`app.js`), and a flyout is the one
+ * shape that cannot work here: the longest submenu is a playlist's tracklist,
+ * and at a phone's 400px there is nowhere for a second panel to go. Expanding
+ * 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
+ * { divider: true }
+ */
+
+// Kept away from the viewport edges; `.ctx-menu` sets the width this assumes.
+const MARGIN = 8;
+
+function useMenu() {
+ const [menu, setMenu] = useState(null); // { x, y, items }
+
+ const close = useCallback(() => setMenu(null), []);
+
+ /**
+ * Open at `e`. A `contextmenu` event carries the pointer; a click on a dots
+ * button carries only the button, so the menu hangs off its bottom-right —
+ * read synchronously, because `currentTarget` is null once the handler has
+ * returned.
+ */
+ const openAt = useCallback((e, items) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.type === 'contextmenu') {
+ setMenu({ x: e.clientX, y: e.clientY, items });
+ return;
+ }
+ const r = e.currentTarget.getBoundingClientRect();
+ setMenu({ x: r.right, y: r.bottom + 4, items });
+ }, []);
+
+ return { menu, openAt, close };
+}
+
+function MenuItems({ items, depth, onClose }) {
+ const [expanded, setExpanded] = useState(null);
+
+ return html`
+ ${items.map((it, i) => {
+ if (it.divider) return html`<div class="ctx-menu-divider" key=${`d${i}`}></div>`;
+
+ if (it.items) {
+ const open = expanded === i;
+ 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); }}>
+ ${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>`)}
+ `;
+ }
+
+ return html`
+ <button class="ctx-menu-item ${it.danger ? 'danger' : ''}" key=${it.key || it.label}
+ style=${depth ? `padding-left: ${14 + depth * 14}px` : null}
+ disabled=${!!it.disabled}
+ onClick=${() => {
+ // Closed first: an action that opens a dialog of its own must not
+ // leave this hanging over it.
+ onClose();
+ if (it.onSelect) it.onSelect();
+ }}>
+ ${it.icon && html`<${Icon} name=${it.icon} cls="ctx-menu-icon" />`}
+ <span class="ctx-menu-label">${it.label}</span>
+ ${it.hint && html`<span class="ctx-menu-hint">${it.hint}</span>`}
+ </button>
+ `;
+ })}
+ `;
+}
+
+function Menu({ x, y, items, onClose }) {
+ const ref = useRef(null);
+ const [place, setPlace] = useState(null);
+
+ // Measure, then place. A menu opened near the right or bottom edge has to
+ // flip rather than be clipped, and how tall it is depends on its own items —
+ // so the number cannot be written down, it has to be read.
+ //
+ // This is not the mutate-then-measure loop CLAUDE.md records: the effect
+ // writes `place`, which moves the panel, and `place` is not in its
+ // dependencies. Position does not change the size being measured, so it
+ // cannot wake itself.
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const r = el.getBoundingClientRect();
+ const left = Math.max(MARGIN, Math.min(x, window.innerWidth - r.width - MARGIN));
+ const top = (y + r.height > window.innerHeight - MARGIN)
+ ? Math.max(MARGIN, y - r.height)
+ : y;
+ setPlace({ left, top });
+ // eslint-disable-next-line
+ }, [x, y, items]);
+
+ useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape') onClose(); };
+ // `mousedown`, not `click`: the click that follows the right-click which
+ // opened this would otherwise close it again before anything is drawn.
+ const onDown = (e) => {
+ if (ref.current && !ref.current.contains(e.target)) onClose();
+ };
+ // Scroll closes it, and this one is not optional. The media toolbars are
+ // sticky bands and the grid scrolls underneath them, so a menu that
+ // survives a scroll is a menu still pointing at the album it was opened
+ // on while sitting over a completely different one.
+ document.addEventListener('keydown', onKey);
+ document.addEventListener('mousedown', onDown);
+ window.addEventListener('scroll', onClose, true);
+ window.addEventListener('resize', onClose);
+ return () => {
+ document.removeEventListener('keydown', onKey);
+ document.removeEventListener('mousedown', onDown);
+ window.removeEventListener('scroll', onClose, true);
+ window.removeEventListener('resize', onClose);
+ };
+ }, [onClose]);
+
+ return html`
+ <div class="ctx-menu" ref=${ref} role="menu"
+ style=${place
+ ? `left: ${place.left}px; top: ${place.top}px`
+ // Drawn where asked but not yet shown, for the one frame it takes to
+ // find out how big it is — otherwise it visibly jumps into place.
+ : `left: ${x}px; top: ${y}px; visibility: hidden`}>
+ <${MenuItems} items=${items} depth=${0} onClose=${onClose} />
+ </div>
+ `;
+}
+
+/**
+ * The dots button. A separate export because every call site needs the same
+ * one and because it must be a real `<button>` sibling of the row's own
+ * action, never nested inside it — see `music-app.js`.
+ */
+function MenuDots({ onOpen, title, cls = '' }) {
+ return html`
+ <button class="ctx-dots ${cls}" title=${title || ''} aria-haspopup="menu"
+ onClick=${onOpen}>
+ <${Icon} name="dots" />
+ </button>
+ `;
+}
+
+export { Menu, MenuDots, useMenu };
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 60a7b00..15fbcf9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
@@ -1,5 +1,5 @@
import {
- html, useState, useEffect, useMemo,
+ html, useState, useEffect, useMemo, useCallback,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
import { Icon } from './icon.js';
@@ -7,6 +7,7 @@ import { MediaThumb, LazyTile } from './video-app.js';
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';
// -- Music --------------------------------------------------------------------
//
@@ -250,7 +251,7 @@ function DiscPlaceholder({ cls }) {
`;
}
-function AlbumCard({ album, transportRef, gekRef, musicbrainzEnabled, onOpen }) {
+function AlbumCard({ album, transportRef, gekRef, musicbrainzEnabled, onOpen, onMenu }) {
const repTrack = album.tracks.find((tr) => tr.thumb_hash) || album.tracks[0];
const tRef = repTrack._tRef || transportRef;
const gRef = repTrack._gRef || gekRef;
@@ -258,12 +259,18 @@ function AlbumCard({ album, transportRef, gekRef, musicbrainzEnabled, onOpen })
const meta = useMusicMeta(tRef, repTrack.id, needsLookup);
const coverHash = repTrack.thumb_hash || (meta && meta.cover_thumb_hash) || null;
+ // Right-click the tile, or press the dots over its cover: the same menu,
+ // because neither affordance covers everyone (menu.js).
+ const openMenu = (e) => onMenu(e, album.tracks, 0);
+
return html`
- <div class="music-card" onClick=${onOpen}>
+ <div class="music-card" onClick=${onOpen} onContextMenu=${openMenu}>
${coverHash
? html`<${MediaThumb} thumbHash=${coverHash} alt=${album.album}
cls="music-cover" transportRef=${tRef} gekRef=${gRef} />`
: html`<${DiscPlaceholder} cls="music-cover" />`}
+ <${MenuDots} onOpen=${openMenu} cls="music-card-dots"
+ title=${t('music.menu_more')} />
<div class="music-card-info">
<div class="music-card-title">${album.album}</div>
<div class="music-card-sub">${album.artist}</div>
@@ -275,7 +282,7 @@ function AlbumCard({ album, transportRef, gekRef, musicbrainzEnabled, onOpen })
// -- detail modal: tracklist + play/play-all -------------------------------
-function MusicDetailModal({ album, transportRef, gekRef, musicbrainzEnabled, onClose, onPlayQueue }) {
+function MusicDetailModal({ album, transportRef, gekRef, musicbrainzEnabled, onClose, onPlayQueue, onMenu }) {
const repTrack = album.tracks.find((tr) => tr.thumb_hash) || album.tracks[0];
const tRef = repTrack._tRef || transportRef;
const gRef = repTrack._gRef || gekRef;
@@ -294,7 +301,8 @@ function MusicDetailModal({ album, transportRef, gekRef, musicbrainzEnabled, onC
<${Icon} name="close" /></button>
</div>
<div class="music-detail-body">
- <div class="music-detail-header">
+ <div class="music-detail-header"
+ onContextMenu=${(e) => onMenu(e, album.tracks, 0)}>
${coverHash
? html`<${MediaThumb} thumbHash=${coverHash} alt=${album.album}
cls="music-detail-cover" transportRef=${tRef} gekRef=${gRef} />`
@@ -302,19 +310,32 @@ function MusicDetailModal({ album, transportRef, gekRef, musicbrainzEnabled, onC
<div class="music-detail-meta">
<div class="music-detail-artist">${album.artist}</div>
${meta && meta.confidence ? html`<div class="music-detail-date">${meta.release_date || ''}</div>` : ''}
- <button class="admin-btn" onClick=${() => { onPlayQueue(album.tracks, 0); onClose(); }}>
- <${Icon} name="play" /> ${t('music.play_all')}
- </button>
+ <div class="music-detail-actions">
+ <button class="admin-btn" onClick=${() => { onPlayQueue(album.tracks, 0); onClose(); }}>
+ <${Icon} name="play" /> ${t('music.play_all')}
+ </button>
+ <${MenuDots} onOpen=${(e) => onMenu(e, album.tracks, 0)}
+ title=${t('music.menu_more')} />
+ </div>
</div>
</div>
<div class="music-tracklist">
- ${album.tracks.map((tr, i) => html`
- <button class="music-track-row" key=${tr.id}
- onClick=${() => { onPlayQueue(album.tracks, i); onClose(); }}>
- <span class="music-track-no">${tr.track_no || (i + 1)}</span>
- <span class="music-track-title">${tr.display_title || tr.name}</span>
- <span class="music-track-duration">${formatTime(tr.duration || 0)}</span>
- </button>
+ ${/* A row carries two actions now — play it, and open its menu —
+ and a button cannot contain another button: the browser
+ reparents the inner one and the row comes apart. The row is a
+ div holding both. */
+ album.tracks.map((tr, i) => html`
+ <div class="music-track-row" key=${tr.id}
+ onContextMenu=${(e) => onMenu(e, [tr], 0)}>
+ <button class="music-track-main"
+ onClick=${() => { onPlayQueue(album.tracks, i); onClose(); }}>
+ <span class="music-track-no">${tr.track_no || (i + 1)}</span>
+ <span class="music-track-title">${tr.display_title || tr.name}</span>
+ <span class="music-track-duration">${formatTime(tr.duration || 0)}</span>
+ </button>
+ <${MenuDots} onOpen=${(e) => onMenu(e, [tr], 0)}
+ title=${t('music.menu_more')} />
+ </div>
`)}
</div>
</div>
@@ -327,7 +348,7 @@ function MusicDetailModal({ album, transportRef, gekRef, musicbrainzEnabled, onC
// `units` is one page of `{ artist, album }`. An artist whose albums straddle
// two pages gets its heading on both.
-function AlbumGrid({ units, transportRef, gekRef, musicbrainzEnabled, onPlayQueue }) {
+function AlbumGrid({ units, transportRef, gekRef, musicbrainzEnabled, onPlayQueue, onMenu }) {
const [detail, setDetail] = useState(null); // the album object
const artists = [];
for (const u of units) {
@@ -344,7 +365,8 @@ function AlbumGrid({ units, transportRef, gekRef, musicbrainzEnabled, onPlayQueu
${a.albums.map((album) => html`
<${LazyTile} key=${album.artist + '::' + album.album} cls="music-tile-slot">
<${AlbumCard} album=${album} transportRef=${transportRef} gekRef=${gekRef}
- musicbrainzEnabled=${musicbrainzEnabled} onOpen=${() => setDetail(album)} />
+ musicbrainzEnabled=${musicbrainzEnabled} onOpen=${() => setDetail(album)}
+ onMenu=${onMenu} />
</${LazyTile}>
`)}
</div>
@@ -352,7 +374,7 @@ function AlbumGrid({ units, transportRef, gekRef, musicbrainzEnabled, onPlayQueu
`)}
${detail && html`
<${MusicDetailModal} album=${detail} transportRef=${transportRef} gekRef=${gekRef}
- musicbrainzEnabled=${musicbrainzEnabled}
+ musicbrainzEnabled=${musicbrainzEnabled} onMenu=${onMenu}
onClose=${() => setDetail(null)} onPlayQueue=${onPlayQueue} />
`}
`;
@@ -367,33 +389,40 @@ function AlbumGrid({ units, transportRef, gekRef, musicbrainzEnabled, onPlayQueu
// exists here, unlike Videos' per-episode thumbnail). Reuses the plain
// numbered-row style Mode A's own tracklist already uses instead
// (music-track-row) — track number, title, duration, no icon box.
-function FlatTrackRow({ track, index, onPlay }) {
+function FlatTrackRow({ track, index, onPlay, onMenu }) {
const num = track.track_no || (index != null ? index + 1 : null);
+ const openMenu = (e) => onMenu(e, [track], 0);
return html`
- <button class="music-track-row music-flat-track" onClick=${onPlay}>
- <span class="music-track-no">${num || ''}</span>
- <span class="music-track-title">${track.display_title || track.name}</span>
- <span class="music-track-duration">${formatTime(track.duration || 0)}</span>
- </button>
+ <div class="music-track-row music-flat-track" onContextMenu=${openMenu}>
+ <button class="music-track-main" onClick=${onPlay}>
+ <span class="music-track-no">${num || ''}</span>
+ <span class="music-track-title">${track.display_title || track.name}</span>
+ <span class="music-track-duration">${formatTime(track.duration || 0)}</span>
+ </button>
+ <${MenuDots} onOpen=${openMenu} title=${t('music.menu_more')} />
+ </div>
`;
}
-function FlatAlbumFolder({ album, onPlayQueue }) {
+function FlatAlbumFolder({ album, onPlayQueue, onMenu }) {
const [open, setOpen] = useState(false);
return html`
<div class="video-flat-folder">
- <div class="video-flat-row" onClick=${() => setOpen((v) => !v)}>
+ <div class="video-flat-row" onClick=${() => setOpen((v) => !v)}
+ onContextMenu=${(e) => onMenu(e, album.tracks, 0)}>
<div class="music-flat-thumb video-thumb-empty"><${Icon} name="folder" /></div>
<div class="video-flat-info">
<div class="video-flat-title">${album.album}</div>
<div class="video-flat-sub">${t('music.n_tracks', { n: album.tracks.length })}</div>
</div>
+ <${MenuDots} onOpen=${(e) => onMenu(e, album.tracks, 0)}
+ title=${t('music.menu_more')} />
<${Icon} name="chevron" cls=${open ? 'video-flat-chevron open' : 'video-flat-chevron'} />
</div>
${open && html`
<div class="music-flat-children">
${album.tracks.map((tr, i) => html`
- <${FlatTrackRow} key=${tr.id} track=${tr} index=${i}
+ <${FlatTrackRow} key=${tr.id} track=${tr} index=${i} onMenu=${onMenu}
onPlay=${() => onPlayQueue(album.tracks, i)} />
`)}
</div>
@@ -402,13 +431,17 @@ function FlatAlbumFolder({ album, onPlayQueue }) {
`;
}
-function FlatArtistFolder({ artist, onPlayQueue }) {
+function FlatArtistFolder({ artist, onPlayQueue, onMenu }) {
const [open, setOpen] = useState(false);
const singleAlbum = artist.albums.length === 1 ? artist.albums[0] : null;
- const trackCount = artist.albums.reduce((n, a) => n + a.tracks.length, 0);
+ // Every track under this artist, albums and loose singles alike, in the
+ // order they are drawn — what "play this artist" has to mean.
+ const allTracks = artist.albums.flatMap((a) => a.tracks);
+ const trackCount = allTracks.length;
return html`
<div class="video-flat-folder">
- <div class="video-flat-row" onClick=${() => setOpen((v) => !v)}>
+ <div class="video-flat-row" onClick=${() => setOpen((v) => !v)}
+ onContextMenu=${(e) => onMenu(e, allTracks, 0)}>
<div class="music-flat-thumb video-thumb-empty"><${Icon} name="folder" /></div>
<div class="video-flat-info">
<div class="video-flat-title">${artist.artist}</div>
@@ -417,6 +450,8 @@ function FlatArtistFolder({ artist, onPlayQueue }) {
? singleAlbum.album : t('music.n_tracks', { n: trackCount })}
</div>
</div>
+ <${MenuDots} onOpen=${(e) => onMenu(e, allTracks, 0)}
+ title=${t('music.menu_more')} />
<${Icon} name="chevron" cls=${open ? 'video-flat-chevron open' : 'video-flat-chevron'} />
</div>
${open && html`
@@ -433,10 +468,11 @@ function FlatArtistFolder({ artist, onPlayQueue }) {
one indent level deeper than its loose siblings would be. */
artist.albums.map((album) => (album.isUnknown
? album.tracks.map((tr, i) => html`
- <${FlatTrackRow} key=${tr.id} track=${tr} index=${i}
+ <${FlatTrackRow} key=${tr.id} track=${tr} index=${i} onMenu=${onMenu}
onPlay=${() => onPlayQueue(album.tracks, i)} />
`)
- : html`<${FlatAlbumFolder} key=${album.album} album=${album} onPlayQueue=${onPlayQueue} />`
+ : html`<${FlatAlbumFolder} key=${album.album} album=${album}
+ onPlayQueue=${onPlayQueue} onMenu=${onMenu} />`
))}
</div>
`}
@@ -445,13 +481,14 @@ function FlatArtistFolder({ artist, onPlayQueue }) {
}
// `items` is one page of rows, already sorted by MusicApp.
-function FlatList({ items, onPlayQueue }) {
+function FlatList({ items, onPlayQueue, onMenu }) {
return html`
<div class="video-flat-list">
${items.map((it) => it.kind === 'track'
- ? html`<${FlatTrackRow} key=${it.track.id} track=${it.track}
+ ? html`<${FlatTrackRow} key=${it.track.id} track=${it.track} onMenu=${onMenu}
onPlay=${() => onPlayQueue([it.track], 0)} />`
- : html`<${FlatArtistFolder} key=${it.artist.artist} artist=${it.artist} onPlayQueue=${onPlayQueue} />`)}
+ : html`<${FlatArtistFolder} key=${it.artist.artist} artist=${it.artist}
+ onPlayQueue=${onPlayQueue} onMenu=${onMenu} />`)}
</div>
`;
}
@@ -466,6 +503,28 @@ function MusicApp({
const [mode, setMode] = useState(loadViewMode);
const [filter, setFilter] = useState('');
const musicbrainzEnabled = musicbrainzConfig ? musicbrainzConfig.enabled : true;
+ // 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();
+
+ // 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
+ // have a place to start from, they have a place to go.
+ // `onPlayQueue(tracks, startIndex, op)` — three arguments, never four. The
+ // page that owns this view adds the group and its transport before passing
+ // it on to the shell; a view has no `source` to give and must not invent an
+ // argument slot for one.
+ const onMenu = useCallback((e, tracks, startIndex) => {
+ if (!tracks || !tracks.length) return;
+ openAt(e, [
+ { label: t('music.menu_play'), icon: 'play',
+ onSelect: () => onPlayQueue(tracks, startIndex || 0) },
+ { label: t('music.menu_play_next'), icon: 'playnext',
+ onSelect: () => onPlayQueue(tracks, 0, 'next') },
+ { label: t('music.menu_enqueue'), icon: 'plus',
+ onSelect: () => onPlayQueue(tracks, 0, 'append') },
+ ]);
+ }, [openAt, onPlayQueue]);
useEffect(() => { setMode(loadViewMode()); }, [groupId]);
useEffect(() => { setFilter(''); }, [groupId]);
@@ -546,10 +605,12 @@ function MusicApp({
`}
${!empty && mode === 'grid'
? html`<${AlbumGrid} units=${pageUnits} transportRef=${transportRef} gekRef=${gekRef}
- musicbrainzEnabled=${musicbrainzEnabled} onPlayQueue=${onPlayQueue} />`
+ musicbrainzEnabled=${musicbrainzEnabled} onPlayQueue=${onPlayQueue}
+ onMenu=${onMenu} />`
: !empty && html`<${FlatList} items=${pageUnits}
- onPlayQueue=${onPlayQueue} />`}
+ onPlayQueue=${onPlayQueue} onMenu=${onMenu} />`}
`}
+ ${menu && html`<${Menu} ...${menu} onClose=${closeMenu} />`}
`;
}
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 a4a8c5c..fcf122f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
@@ -1,6 +1,7 @@
import {
- html, useState, useEffect, useRef, useCallback,
+ html, useState, useEffect, useReducer, useRef, useCallback,
} from './vendor/htm-preact.js';
+import { queueReducer, emptyQueue } from './queue-ops.js';
import { t } from './i18n.js';
import { Icon } from './icon.js';
import { useStickyBand } from './sticky.js';
@@ -52,21 +53,6 @@ function formatTime(seconds) {
return `${m}:${String(s).padStart(2, '0')}`;
}
-function shuffledOrder(n, keepFirst) {
- const order = Array.from({ length: n }, (_, i) => i);
- // Fisher-Yates, then move keepFirst to the front so shuffling on doesn't
- // interrupt whatever is already playing.
- for (let i = order.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [order[i], order[j]] = [order[j], order[i]];
- }
- if (keepFirst != null) {
- const at = order.indexOf(keepFirst);
- if (at > 0) { order.splice(at, 1); order.unshift(keepFirst); }
- }
- return order;
-}
-
// Bounded: only the currently playing track plus the read-ahead window are
// ever worth holding in memory. Older blob URLs are revoked, not merely
// dropped — otherwise every track played in a session leaks its object URL.
@@ -188,9 +174,12 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) {
const blobInsertRef = useRef(0);
const loadTokenRef = useRef(0);
- const [tracks, setTracks] = useState([]);
- const [order, setOrder] = useState([]);
- const [pos, setPos] = useState(0); // index into `order`
+ // The queue: every entry, the play order over it, and where in that order
+ // we are (docs/playlists.md §9.3). One reducer rather than three useStates
+ // because "add to queue" derives its new indices from the current track
+ // count, and two appends batched into one tick cannot both see it.
+ const [queueState, dispatch] = useReducer(queueReducer, null, emptyQueue);
+ const { tracks, order, pos } = queueState;
const [playing, setPlaying] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -209,14 +198,15 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) {
// through the whole list in an instant.
const consecutiveFailuresRef = useRef(0);
const MAX_CONSECUTIVE_FAILURES = 5;
+ const prefetchAfterInsertRef = useRef(false);
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) setPos(pos + 1);
- else if (repeat === 'all') setPos(0);
+ if (pos + 1 < order.length) dispatch({ type: 'skipTo', pos: pos + 1 });
+ else if (repeat === 'all') dispatch({ type: 'skipTo', pos: 0 });
}, [order.length, pos, repeat]);
// Stops playback the moment this bar goes away for any reason -- the
@@ -342,17 +332,38 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) {
// effect always re-runs rather than bailing out on reference equality.
useEffect(() => {
if (!queue) return;
- const n = queue.tracks.length;
- const initialOrder = shuffle ? shuffledOrder(n, queue.startIndex) : Array.from({ length: n }, (_, i) => i);
- const startPos = shuffle ? 0 : queue.startIndex;
- setTracks(queue.tracks);
- setOrder(initialOrder);
- setPos(startPos);
- setError('');
+ const op = queue.op || 'replace';
+ dispatch({
+ type: op === 'next' ? 'insertNext' : op === 'append' ? 'append' : 'replace',
+ tracks: queue.tracks,
+ startIndex: queue.startIndex,
+ shuffle,
+ });
+ // A deliberate queue change is new material to try, so the runaway bound
+ // starts over; the bound exists to stop an automatic advance burning
+ // through a bad queue, not to hold a user's own action against them.
consecutiveFailuresRef.current = 0;
+ if (op === 'replace') {
+ setError('');
+ } else {
+ // Nothing that is playing changed, so the play effect below will not
+ // run and will not warm anything — but "play next" just put a track at
+ // pos + 1 that nobody has fetched. Flagged here, acted on once `order`
+ // has actually been rebuilt.
+ prefetchAfterInsertRef.current = true;
+ }
// Playback itself starts from the effect below, keyed on [tracks, order, pos].
}, [queue]);
+ useEffect(() => {
+ if (!prefetchAfterInsertRef.current) return;
+ prefetchAfterInsertRef.current = false;
+ if (order.length) prefetchNext(pos);
+ // Deliberately not keyed on `pos`: this runs for a queue edit, and the
+ // ordinary advance is already covered where the current track loads.
+ // eslint-disable-next-line
+ }, [order]);
+
// Loads and plays whatever `pos` now points to. Runs after the queue
// effect above (pos/order/tracks all just changed together) and also
// after skipNext/skipPrev/onEnded update `pos` alone.
@@ -406,7 +417,7 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) {
saveVolume(volume);
}, [volume]);
- const skipTo = useCallback((newPos) => setPos(newPos), []);
+ const skipTo = useCallback((newPos) => dispatch({ type: 'skipTo', pos: newPos }), []);
const skipNext = useCallback(() => {
if (order.length === 0) return;
@@ -441,22 +452,15 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) {
else { audio.play().then(() => setPlaying(true)).catch(() => {}); }
}, [playing]);
+ // Reshuffling keeps the currently playing track in place — turning shuffle
+ // on mid-album must not interrupt what's already playing. Which track that
+ // is, is `order[pos]`; see queue-ops.js on why it is not looked up by id.
const toggleShuffle = useCallback(() => {
- setShuffle((prev) => {
- const next = !prev;
- saveShuffle(next);
- // Reshuffling keeps the currently playing track in place — turning
- // shuffle on mid-album must not interrupt what's already playing.
- const currentId = tracks[order[pos]] && tracks[order[pos]].id;
- const currentIdx = tracks.findIndex((tr) => tr.id === currentId);
- const newOrder = next
- ? shuffledOrder(tracks.length, currentIdx)
- : Array.from({ length: tracks.length }, (_, i) => i);
- setOrder(newOrder);
- setPos(next ? 0 : currentIdx);
- return next;
- });
- }, [tracks, order, pos]);
+ const next = !shuffle;
+ setShuffle(next);
+ saveShuffle(next);
+ dispatch({ type: 'reshuffle', shuffle: next });
+ }, [shuffle]);
const cycleRepeat = useCallback(() => {
setRepeat((prev) => {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/queue-ops.js b/packages/meshbay-hub/src/meshbay_hub/static/queue-ops.js
new file mode 100644
index 0000000..bd48a30
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/queue-ops.js
@@ -0,0 +1,147 @@
+/**
+ * The music player's queue, as a reducer over `{ tracks, order, pos }`.
+ *
+ * `tracks` is every entry the queue holds, in the order they were added.
+ * `order` is indices into it — the *play* order, which is what shuffle
+ * rewrites without disturbing `tracks`. `pos` walks `order`. The track playing
+ * is `tracks[order[pos]]`, and `docs/playlists.md` §9 is why these three live
+ * here rather than as three pieces of component state.
+ *
+ * The queue used to be replaceable and nothing else: every call to
+ * `onPlayQueue` reset all three together, which is all an album needs. "Play
+ * next" and "add to queue" do not replace, and building them on three separate
+ * `useState`s ships a defect — `setOrder` needs the length `setTracks` is about
+ * to produce and cannot see it, so two enqueues batched into one tick both read
+ * the stale length and write indices past the end. A reducer sees one state.
+ *
+ * No imports, and there must be none: the whole module is executed standalone
+ * by `test_queue_ops.py`, the way `source-merge.js` is, and the queue is
+ * untested from the moment it cannot be.
+ */
+
+/**
+ * A play order over `n` tracks, shuffled, with `keepFirst` moved to the front
+ * so turning shuffle on mid-album does not interrupt what is already playing.
+ *
+ * `rand` is injectable for the tests alone — callers pass nothing.
+ */
+function shuffledOrder(n, keepFirst, rand) {
+ const rnd = rand || Math.random;
+ const order = Array.from({ length: n }, (_, i) => i);
+ for (let i = order.length - 1; i > 0; i--) {
+ const j = Math.floor(rnd() * (i + 1));
+ [order[i], order[j]] = [order[j], order[i]];
+ }
+ if (keepFirst != null) {
+ const at = order.indexOf(keepFirst);
+ if (at > 0) { order.splice(at, 1); order.unshift(keepFirst); }
+ }
+ return order;
+}
+
+function identityOrder(n) {
+ return Array.from({ length: n }, (_, i) => i);
+}
+
+function emptyQueue() {
+ return { tracks: [], order: [], pos: 0 };
+}
+
+/**
+ * `{ tracks, order, pos }` after `action`. Pure, and never mutates `state`:
+ * the player holds this in `useReducer`, and a mutated array would not
+ * re-render the queue panel that is looking at it.
+ *
+ * replace { tracks, startIndex, shuffle } the whole queue, as before
+ * append { tracks } at the end of the play order
+ * insertNext { tracks } right after what is playing
+ * removeAt { at } `at` indexes `order`, not `tracks`
+ * skipTo { pos }
+ * reshuffle { shuffle } keeping the current track playing
+ */
+function queueReducer(state, action) {
+ switch (action.type) {
+ case 'replace': {
+ const incoming = action.tracks || [];
+ const n = incoming.length;
+ const start = action.startIndex || 0;
+ return {
+ tracks: incoming,
+ // Identical to what the player did inline before this module existed:
+ // shuffled with the requested track pulled to the front and played from
+ // position 0, or the plain order played from the requested index.
+ order: action.shuffle
+ ? shuffledOrder(n, start, action.rand)
+ : identityOrder(n),
+ pos: action.shuffle ? 0 : start,
+ };
+ }
+
+ case 'append':
+ case 'insertNext': {
+ const incoming = action.tracks || [];
+ if (!incoming.length) return state;
+ // The one line this module exists for: the new indices are derived from
+ // the length in `state`, which is the length after every action already
+ // applied — not from a closure captured when the click handler was built.
+ const added = incoming.map((_, i) => state.tracks.length + i);
+ const tracks = [...state.tracks, ...incoming];
+ if (action.type === 'append') {
+ return { tracks, order: [...state.order, ...added], pos: state.pos };
+ }
+ // Appended tracks are *not* reshuffled into the middle when shuffle is
+ // on: "add to queue" means at the end, and "play next" means next, in
+ // the order given. Reshuffling either would be the player deciding it
+ // knew better than the verb the user pressed.
+ const at = state.order.length ? state.pos + 1 : 0;
+ return {
+ tracks,
+ order: [...state.order.slice(0, at), ...added, ...state.order.slice(at)],
+ pos: state.pos,
+ };
+ }
+
+ case 'removeAt': {
+ const { at } = action;
+ if (at < 0 || at >= state.order.length) return state;
+ const order = [...state.order.slice(0, at), ...state.order.slice(at + 1)];
+ // Dropped from the play order only. Reindexing `tracks` would mean
+ // rewriting every entry of `order` for one removal, and the orphan costs
+ // a pointer — `tracks` is bounded by the queue, not by the session.
+ //
+ // Removing what is playing leaves `pos` where it is, so the next track
+ // slides into that slot and the player's load effect starts it: that is
+ // what removing the current track is expected to do.
+ let pos = state.pos;
+ if (at < state.pos) pos -= 1;
+ if (pos > order.length - 1) pos = Math.max(0, order.length - 1);
+ return { tracks: state.tracks, order, pos };
+ }
+
+ case 'skipTo':
+ return { ...state, pos: action.pos };
+
+ case 'reshuffle': {
+ // The track playing is `order[pos]` — an index into `tracks` — so that
+ // *is* the index to keep. The first version of this recovered it by
+ // looking up the current entry's id and searching `tracks` for it, which
+ // agreed with `order[pos]` only while the queue held no duplicates. A
+ // queue can now hold duplicates (enqueue the same track twice, add an
+ // album that overlaps a playlist), and the id search then jumps playback
+ // to the first copy.
+ const currentIdx = state.order.length ? state.order[state.pos] : null;
+ return {
+ tracks: state.tracks,
+ order: action.shuffle
+ ? shuffledOrder(state.tracks.length, currentIdx, action.rand)
+ : identityOrder(state.tracks.length),
+ pos: action.shuffle ? 0 : (currentIdx == null ? 0 : currentIdx),
+ };
+ }
+
+ default:
+ return state;
+ }
+}
+
+export { queueReducer, shuffledOrder, identityOrder, emptyQueue };
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 3759650..8e3f17c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -657,9 +657,10 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
}
}, [allEntries, onPlayQueue, connectGroup]);
- const handleMusicPlay = useCallback((tracks, startIndex) => {
- setVideoEntry(null);
- if (onPlayQueue) onPlayQueue(tracks, startIndex);
+ // Same as group-page.js's wrapper: `op` must be named to survive the hop.
+ const handleMusicPlay = useCallback((tracks, startIndex, op) => {
+ if (!op || op === 'replace') setVideoEntry(null);
+ if (onPlayQueue) onPlayQueue(tracks, startIndex, null, op);
}, [onPlayQueue]);
const downloadForModal = useCallback(async (entry) => {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index f46e5c2..5642262 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -4167,6 +4167,8 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
.music-tile-slot { min-height: 200px; }
.music-card {
+ /* The dots button floats over the cover's top-right corner. */
+ position: relative;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
@@ -4288,6 +4290,25 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
cursor: pointer;
text-align: left;
}
+/* The row holds two controls — play it, and its menu — so it is a div, and
+ the play action is the button inside it. A button cannot contain another
+ button: the browser reparents the inner one and the row comes apart.
+ music-player.js's queue panel has no menu and is still a plain button, which
+ is why `cursor` stays on the row rather than moving to the child. */
+.music-track-main {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex: 1;
+ min-width: 0;
+ padding: 0;
+ border: none;
+ background: none;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
.music-track-row:hover { background: var(--bg-raised); border-color: var(--border); }
.music-track-row.active { border-color: var(--accent); color: var(--accent); }
.music-track-no { width: 24px; flex-shrink: 0; color: var(--text-dim); text-align: right; }
@@ -5000,3 +5021,123 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
white-space: nowrap;
border: 0;
}
+
+/* ── Pop-up menus (menu.js) ────────────────────────────────────────────────
+ One panel, opened at a point, for right-click on a cover and for the dots
+ button beside a track. `position: fixed` because the coordinates come from
+ a pointer or a `getBoundingClientRect()` — both viewport-relative, and an
+ absolutely-positioned panel would be offset by whatever happens to be
+ scrolled. menu.js measures and flips it; nothing here knows where it goes.
+ docs/playlists.md §10.1. */
+
+.ctx-menu {
+ position: fixed;
+ /* Above everything, modals included: this menu is opened *from* the album
+ detail overlay, and `.video-overlay` is itself `position: fixed` at 200.
+ Equal values would leave it to DOM order, which is not a decision. */
+ z-index: 300;
+ min-width: 220px;
+ max-width: calc(100vw - 16px);
+ /* A playlist's tracklist expands inside this panel rather than flying out
+ sideways, so it can be far taller than the window. Same answer the
+ account menu's language list already has. */
+ max-height: calc(100vh - 16px);
+ overflow-x: hidden;
+ overflow-y: auto;
+ padding: 4px 0;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ box-shadow: var(--shadow-lg);
+}
+
+.ctx-menu-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 9px 14px;
+ border: none;
+ background: none;
+ color: var(--text);
+ font: inherit;
+ font-size: 0.9em;
+ text-align: left;
+ cursor: pointer;
+}
+.ctx-menu-item:hover:not(:disabled) { background: var(--sidebar-hover); }
+.ctx-menu-item:disabled { color: var(--text-dim); cursor: default; }
+.ctx-menu-item.danger { color: var(--error); }
+
+.ctx-menu-icon { width: 16px; height: 16px; flex-shrink: 0; }
+.ctx-menu-label {
+ flex: 1;
+ /* A track title is arbitrarily long and the panel is not: one line, cut,
+ rather than a menu as wide as the longest filename in the library. */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.ctx-menu-hint { color: var(--text-dim); font-size: 0.85em; flex-shrink: 0; }
+.ctx-menu-caret {
+ width: 14px; height: 14px; flex-shrink: 0;
+ color: var(--text-dim);
+ transition: transform 0.15s;
+}
+.ctx-menu-caret.flip { transform: rotate(180deg); }
+
+.ctx-menu-divider { height: 1px; background: var(--border); margin: 4px 0; }
+
+.ctx-menu-empty {
+ padding: 8px 14px;
+ color: var(--text-dim);
+ font-size: 0.85em;
+}
+
+/* The dots button on a cover or a track row. Revealed on hover where there is
+ a pointer to hover with, and permanently visible where there is not — a
+ touchscreen has no hover state, and this is the *only* way in there. */
+.ctx-dots {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 28px; height: 28px;
+ padding: 0;
+ border: none;
+ border-radius: 6px;
+ background: none;
+ color: var(--text-dim);
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 0.12s, background 0.12s;
+}
+.ctx-dots svg { width: 18px; height: 18px; }
+.ctx-dots:hover { background: var(--sidebar-hover); color: var(--text); }
+.ctx-dots:focus-visible { opacity: 1; outline: 2px solid var(--border-focus); }
+.music-card:hover .ctx-dots,
+.music-track-row:hover .ctx-dots { opacity: 1; }
+
+@media (hover: none), (pointer: coarse) {
+ .ctx-dots { opacity: 1; }
+}
+
+/* On a cover, floated over the top-right corner rather than taking a row of
+ its own — the grid's tiles are sized by their art. */
+.music-card-dots {
+ position: absolute;
+ top: 6px; right: 6px;
+ background: rgba(0, 0, 0, 0.55);
+ color: #fff;
+ backdrop-filter: blur(2px);
+}
+.music-card-dots:hover { background: rgba(0, 0, 0, 0.75); color: #fff; }
+
+/* A flat folder row expands on click; its menu button sits in the same row and
+ must not take the whole width with it. */
+.video-flat-row .ctx-dots { margin-left: auto; }
+.video-flat-row .ctx-dots + .video-flat-chevron { margin-left: 8px; }
+
+/* "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; }
diff --git a/packages/meshbay-hub/tests/harness/music_queue_probe.py b/packages/meshbay-hub/tests/harness/music_queue_probe.py
new file mode 100755
index 0000000..f74ceb2
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/music_queue_probe.py
@@ -0,0 +1,308 @@
+#!/usr/bin/env python3
+"""
+The queue verbs, driven through the real interface in a real browser.
+
+`queue-ops.js` is unit-tested on its own, but everything between a right-click
+and the reducer is not reachable that way: whether the menu opens at all,
+whether the dots button survived being put inside a row that used to be one
+big `<button>` (a button cannot contain a button — the browser reparents the
+inner one and the row comes apart), and whether "play next" reaches the player
+with its `op` intact through two components and the shell.
+
+So this renders the shipped `GroupPage` and the shipped `MusicPlayerBar`
+against a stub node, presses the real controls, and reports the queue after
+each one — read where a person reads it, out of the player's own queue panel.
+
+ music_queue_probe.py
+
+Prints JSON: one entry per step, with the play order and the playing track.
+
+Playback itself cannot happen here: the stub never returns a transport, so
+every track stays loading for ever. That is deliberate and is not what is
+being measured — the queue is.
+"""
+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 = 8751
+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';
+
+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();
+ render(html`<${Harness} />`, document.getElementById('root'));
+
+ if (!await waitFor('.music-card')) return fail('no album grid');
+ 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.
+ cards[0].click();
+ if (!await waitFor('.music-detail .music-tracklist')) return fail('no detail modal');
+ const rows = [...document.querySelectorAll('.music-detail .music-tracklist .music-track-main')];
+ if (rows.length !== 3) return fail('album 1 has ' + rows.length + ' rows');
+ rows[1].click();
+ await sleep(250);
+ await queueNow('play a track');
+
+ // 2. Right-click album 2 → "add to queue".
+ rightClick(document.querySelectorAll('.music-card')[1]);
+ await sleep(150);
+ if (!document.querySelector('.ctx-menu')) return fail('right-click opened no menu');
+ const labels = menuLabels();
+ await clickMenu(2);
+ await queueNow('append album 2', { menu: labels });
+
+ // 3. Dots on album 3 → "play next". The other affordance, and the verb
+ // that has to land at pos + 1 rather than at the end.
+ const dots = document.querySelectorAll('.music-card .ctx-dots');
+ if (dots.length < 3) return fail('only ' + dots.length + ' dots buttons');
+ dots[2].click();
+ await sleep(150);
+ if (!document.querySelector('.ctx-menu')) return fail('dots opened no menu');
+ await clickMenu(1);
+ await queueNow('play next album 3');
+
+ // 4. Shuffle on and off again: the playing track must not change, and the
+ // queue must still hold everything appended to it.
+ document.querySelector('.music-player-transport .music-player-btn').click();
+ await sleep(200);
+ await queueNow('shuffle on');
+ document.querySelector('.music-player-transport .music-player-btn').click();
+ await sleep(200);
+ await queueNow('shuffle off');
+
+ // 5. Replace: playing an album outright discards everything above.
+ document.querySelectorAll('.music-card')[3].click();
+ if (!await waitFor('.music-detail .music-detail-meta .admin-btn')) return fail('no play-all');
+ document.querySelector('.music-detail .music-detail-meta .admin-btn').click();
+ await sleep(250);
+ await queueNow('replace with album 4');
+
+ 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 28ec93e..994ca2e 100644
--- a/packages/meshbay-hub/tests/test_hook_ordering.py
+++ b/packages/meshbay-hub/tests/test_hook_ordering.py
@@ -49,6 +49,9 @@ STATIC_FILES = [
# `useMemo` chain in the tree — a dozen derived lists, each depending on
# the one above it, which is precisely the shape this checks.
"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",
]
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
new file mode 100644
index 0000000..a53047d
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_music_queue.py
@@ -0,0 +1,115 @@
+"""
+Play, play next, add to queue — pressed in a real browser.
+
+`test_queue_ops.py` executes the reducer directly, which is where the index
+arithmetic is proved. It cannot reach any of this: whether a right-click opens
+a menu at all, whether the dots button still works now that a track row is a
+div holding two buttons rather than one big one, and whether `op` survives the
+trip from the menu through the view, the page's wrapper and the shell.
+
+That last one is not hypothetical. Both wrappers took `(tracks, startIndex)`
+and forwarded two arguments, so every "add to queue" in a group arrived at the
+player as a plain play and silently replaced the queue. Nothing about that
+reads as wrong at either end; this probe is what found it.
+
+The probe renders the shipped `GroupPage` and `MusicPlayerBar`, presses the
+real controls, and reads the queue out of the player's own panel.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "music_queue_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 / "queue-ops.js").exists(),
+ reason="Chrome or the SPA sources are not available")
+
+ALBUM = {n: [f"A{n}-t1", f"A{n}-t2", f"A{n}-t3"] for n in (1, 2, 3, 4)}
+
+
+@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_playing_a_track_queues_its_album(steps):
+ """The ordinary path, through a row that is no longer one single button."""
+ s = steps["play a track"]
+ assert s["play"] == ALBUM[1]
+ assert s["playing"] == 1
+ 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_add_to_queue_appends_and_leaves_the_playhead_alone(steps):
+ """The bug this file exists for: this used to replace the queue."""
+ s = steps["append album 2"]
+ assert s["play"] == ALBUM[1] + ALBUM[2]
+ assert s["nowPlaying"] == "A1-t2", "appending moved what was playing"
+
+
+def test_play_next_lands_after_the_playing_track(steps):
+ """Reached through the dots button rather than a right-click, so both
+ affordances are exercised."""
+ s = steps["play next album 3"]
+ assert s["play"] == ["A1-t1", "A1-t2"] + ALBUM[3] + ["A1-t3"] + ALBUM[2]
+ assert s["nowPlaying"] == "A1-t2"
+
+
+def test_shuffling_keeps_everything_that_was_queued(steps):
+ on = steps["shuffle on"]
+ assert sorted(on["play"]) == sorted(ALBUM[1] + ALBUM[2] + ALBUM[3])
+ assert on["playing"] == 0 and on["nowPlaying"] == "A1-t2", (
+ "shuffle moved the track that was already playing")
+
+
+def test_shuffling_off_returns_to_the_order_tracks_were_added_in(steps):
+ """Not to the play order "play next" built: unshuffled *is* the order the
+ tracks arrived in, and that is what it has always meant. A track inserted
+ next while shuffled keeps its place only while shuffle is on."""
+ s = steps["shuffle off"]
+ assert s["play"] == ALBUM[1] + ALBUM[2] + ALBUM[3]
+ assert s["nowPlaying"] == "A1-t2"
+
+
+def test_play_all_replaces_everything(steps):
+ s = steps["replace with album 4"]
+ assert s["play"] == ALBUM[4]
+ assert s["playing"] == 0
+
+
+# ── the wrappers, read rather than driven ────────────────────────────────────
+#
+# The probe proves the chain works for the group page. Search mounts the same
+# view through a wrapper of its own, and there is no cheap way to drive that
+# page's index fetch here — so this reads the one thing that broke.
+
+@pytest.mark.parametrize("name", ["group-page.js", "search-page.js"])
+def test_the_music_wrapper_names_the_op_it_forwards(name):
+ """A wrapper that takes two arguments forwards two, and the third is lost
+ without a word. Both of these did exactly that."""
+ src = (STATIC / name).read_text()
+ marker = ("const onPlayQueue = useCallback((tracks, startIndex, op)"
+ if name == "group-page.js"
+ else "const handleMusicPlay = useCallback((tracks, startIndex, op)")
+ assert marker in src, (
+ f"{name}'s music wrapper no longer names `op`; every 'add to queue' "
+ f"and 'play next' reaching it becomes a plain play")
diff --git a/packages/meshbay-hub/tests/test_queue_ops.py b/packages/meshbay-hub/tests/test_queue_ops.py
new file mode 100644
index 0000000..64b5ca1
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_queue_ops.py
@@ -0,0 +1,319 @@
+"""
+The music player's queue: replace, append, play-next, remove, reshuffle.
+
+The queue was replaceable and nothing else — every `onPlayQueue` reset
+`tracks`, `order` and `pos` together, which is all an album needs. Playlists
+add "play next" and "add to queue", which do not replace, and the three
+`useState`s they would have been built on cannot express an append correctly:
+`setOrder` needs the length `setTracks` is about to produce and cannot see it,
+so two enqueues batched into one tick both read the stale length and write
+indices past the end of `tracks`. `queue-ops.js` is one reducer over one state
+object, which is the only shape in which that is not a defect.
+
+The whole module is executed here rather than a regex-extracted function of it:
+it has no imports precisely so that it can be, and a copy of the reducer in a
+test would keep agreeing with the original right up until one of them changed.
+
+See docs/playlists.md §9.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+SRC = STATIC / "queue-ops.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not SRC.exists(),
+ reason="node or the SPA sources are not available")
+
+IMPORT = re.compile(r"^\s*import\b", re.M)
+EXPORT = re.compile(r"^export \{[^}]*\};?\s*$", re.M)
+
+
+@pytest.fixture(scope="module")
+def module_source():
+ text = SRC.read_text()
+ assert not IMPORT.search(text), (
+ "queue-ops.js has gained an import. It is executed standalone here, "
+ "and the queue is untested from the moment it cannot be — keep the "
+ "module free of imports, or this test needs a bundler")
+ stripped, n = EXPORT.subn("", text)
+ assert n == 1, (
+ "queue-ops.js no longer ends in a single export statement — the test "
+ "can no longer strip it to run the module")
+ return stripped
+
+
+def _run(tmp_path, module_source, body):
+ script = tmp_path / "case.js"
+ script.write_text(f"{module_source}\n{body}\n")
+ out = subprocess.run(
+ ["node", str(script)], capture_output=True, text=True, timeout=30)
+ assert out.returncode == 0, out.stderr
+ return json.loads(out.stdout)
+
+
+def _tracks(*ids):
+ return [{"id": i, "name": f"{i}.flac", "size": 1, "groupId": "g"} for i in ids]
+
+
+def _reduce(tmp_path, module_source, actions, start=None):
+ """Fold `actions` over the reducer, one after another, and report the end
+ state as ids so a test reads as the play order it means."""
+ body = f"""
+ let s = {json.dumps(start) if start else "emptyQueue()"};
+ for (const a of {json.dumps(actions)}) {{
+ // A deterministic shuffle: reverses the order, then keepFirst is
+ // pulled to the front by the reducer itself. Real randomness would
+ // make every shuffle assertion a coin toss.
+ s = queueReducer(s, {{ ...a, rand: () => 0 }});
+ }}
+ console.log(JSON.stringify({{
+ play: s.order.map((i) => s.tracks[i].id),
+ pos: s.pos,
+ playing: s.order.length ? s.tracks[s.order[s.pos]].id : null,
+ nTracks: s.tracks.length,
+ }}));
+ """
+ return _run(tmp_path, module_source, body)
+
+
+# ── replace: the path that already worked, pinned ────────────────────────────
+
+def test_replace_unshuffled_is_the_plain_order_from_the_requested_index(
+ tmp_path, module_source):
+ """What playing track 3 of an album has always done. If this changes,
+ every album in the application changed with it."""
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a", "b", "c", "d"), "startIndex": 2},
+ ])
+ assert out["play"] == ["a", "b", "c", "d"]
+ assert out["pos"] == 2
+ assert out["playing"] == "c"
+
+
+def test_replace_shuffled_keeps_the_requested_track_first(tmp_path, module_source):
+ """Shuffling on a chosen track must not start a different one."""
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a", "b", "c", "d"),
+ "startIndex": 2, "shuffle": True},
+ ])
+ assert out["pos"] == 0
+ assert out["playing"] == "c"
+ assert sorted(out["play"]) == ["a", "b", "c", "d"]
+
+
+def test_replace_discards_the_previous_queue(tmp_path, module_source):
+ """Loading a playlist replaces; it does not accumulate (docs §9.1)."""
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a", "b")},
+ {"type": "replace", "tracks": _tracks("x", "y", "z")},
+ ])
+ assert out["play"] == ["x", "y", "z"]
+ assert out["nTracks"] == 3
+
+
+# ── append and play-next ─────────────────────────────────────────────────────
+
+def test_append_to_an_empty_queue_plays_it(tmp_path, module_source):
+ """Enqueueing with nothing playing has to start something, or the button
+ does nothing at all the first time it is pressed."""
+ out = _reduce(tmp_path, module_source, [
+ {"type": "append", "tracks": _tracks("a", "b")},
+ ])
+ assert out["play"] == ["a", "b"]
+ assert out["playing"] == "a"
+
+
+def test_append_goes_to_the_end_and_does_not_move_the_playhead(
+ tmp_path, module_source):
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a", "b", "c"), "startIndex": 1},
+ {"type": "append", "tracks": _tracks("x", "y")},
+ ])
+ assert out["play"] == ["a", "b", "c", "x", "y"]
+ assert out["playing"] == "b"
+
+
+def test_play_next_lands_immediately_after_what_is_playing(
+ tmp_path, module_source):
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a", "b", "c"), "startIndex": 0},
+ {"type": "insertNext", "tracks": _tracks("x")},
+ ])
+ assert out["play"] == ["a", "x", "b", "c"]
+ assert out["playing"] == "a"
+
+
+def test_play_next_on_the_last_track_still_lands_after_it(tmp_path, module_source):
+ """The splice index is past the end of `order`; a slice must cope rather
+ than dropping the entry silently."""
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a", "b"), "startIndex": 1},
+ {"type": "insertNext", "tracks": _tracks("x")},
+ ])
+ assert out["play"] == ["a", "b", "x"]
+ assert out["playing"] == "b"
+
+
+def test_play_next_while_shuffled_inserts_into_the_play_order(
+ tmp_path, module_source):
+ """Not into `tracks` — the played sequence is `order`, and "next" means
+ next in what is actually being played."""
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a", "b", "c", "d"),
+ "startIndex": 0, "shuffle": True},
+ {"type": "insertNext", "tracks": _tracks("x")},
+ ])
+ assert out["play"][0] == "a"
+ assert out["play"][1] == "x"
+ assert out["playing"] == "a"
+
+
+def test_appending_nothing_is_not_a_change(tmp_path, module_source):
+ """An album card with no tracks must not clear the playhead."""
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a", "b"), "startIndex": 1},
+ {"type": "append", "tracks": []},
+ ])
+ assert out["play"] == ["a", "b"]
+ assert out["playing"] == "b"
+
+
+# ── the defect this module exists to prevent ─────────────────────────────────
+
+def test_two_appends_in_one_tick_do_not_write_indices_past_the_end(
+ tmp_path, module_source):
+ """*The* reason the queue is a reducer (docs §9.3).
+
+ Two `useState`s updated from one event both read the length captured when
+ the handler was built, so the second append's indices collide with the
+ first's — a double click on "add to queue" produced a queue playing the
+ wrong tracks, or holes. Folding through the reducer is what a batched
+ render does, and every index has to be distinct and in range.
+ """
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a", "b")},
+ {"type": "append", "tracks": _tracks("x")},
+ {"type": "append", "tracks": _tracks("y")},
+ ])
+ assert out["play"] == ["a", "b", "x", "y"], (
+ "an append read a stale track count — this is the three-useState bug")
+ assert out["nTracks"] == 4
+
+
+def test_many_appends_stay_in_range(tmp_path, module_source):
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a")},
+ ] + [{"type": "append", "tracks": _tracks(f"t{n}")} for n in range(20)])
+ assert out["play"] == ["a"] + [f"t{n}" for n in range(20)]
+ assert out["nTracks"] == 21
+
+
+# ── removal ──────────────────────────────────────────────────────────────────
+
+def test_removing_the_playing_track_slides_the_next_one_in(tmp_path, module_source):
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a", "b", "c"), "startIndex": 1},
+ {"type": "removeAt", "at": 1},
+ ])
+ assert out["play"] == ["a", "c"]
+ assert out["playing"] == "c"
+
+
+def test_removing_before_the_playhead_keeps_the_same_track_playing(
+ tmp_path, module_source):
+ """The index moved; what is playing must not."""
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a", "b", "c"), "startIndex": 2},
+ {"type": "removeAt", "at": 0},
+ ])
+ assert out["play"] == ["b", "c"]
+ assert out["playing"] == "c"
+
+
+def test_removing_the_last_remaining_track_does_not_leave_pos_dangling(
+ tmp_path, module_source):
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a")},
+ {"type": "removeAt", "at": 0},
+ ])
+ assert out["play"] == []
+ assert out["pos"] == 0
+ assert out["playing"] is None
+
+
+def test_removing_out_of_range_is_not_a_change(tmp_path, module_source):
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a", "b"), "startIndex": 1},
+ {"type": "removeAt", "at": 7},
+ ])
+ assert out["play"] == ["a", "b"]
+ assert out["playing"] == "b"
+
+
+# ── shuffle ──────────────────────────────────────────────────────────────────
+
+def test_shuffling_on_mid_album_does_not_interrupt_what_is_playing(
+ tmp_path, module_source):
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a", "b", "c", "d"), "startIndex": 2},
+ {"type": "reshuffle", "shuffle": True},
+ ])
+ assert out["pos"] == 0
+ assert out["playing"] == "c"
+ assert sorted(out["play"]) == ["a", "b", "c", "d"]
+
+
+def test_shuffling_off_returns_to_the_album_order_at_the_same_track(
+ tmp_path, module_source):
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a", "b", "c", "d"),
+ "startIndex": 2, "shuffle": True},
+ {"type": "reshuffle", "shuffle": False},
+ ])
+ assert out["play"] == ["a", "b", "c", "d"]
+ assert out["playing"] == "c"
+
+
+def test_shuffle_after_an_append_covers_the_appended_tracks(
+ tmp_path, module_source):
+ """`order` is rebuilt from `tracks.length`, so it has to have grown."""
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a", "b")},
+ {"type": "append", "tracks": _tracks("x", "y")},
+ {"type": "reshuffle", "shuffle": True},
+ ])
+ assert sorted(out["play"]) == ["a", "b", "x", "y"]
+ assert out["playing"] == "a"
+
+
+def test_shuffling_a_queue_holding_the_same_track_twice_keeps_the_right_copy(
+ tmp_path, module_source):
+ """A queue could not hold duplicates until "add to queue" existed, and the
+ first reshuffle recovered the current index by searching `tracks` for the
+ playing entry's id — which finds the *first* copy. Enqueue a track that is
+ already in the queue, play the second copy, toggle shuffle, and playback
+ jumped backwards. `order[pos]` is the index and needs no search."""
+ out = _reduce(tmp_path, module_source, [
+ {"type": "replace", "tracks": _tracks("a", "b")},
+ {"type": "append", "tracks": _tracks("a")},
+ {"type": "skipTo", "pos": 2},
+ {"type": "reshuffle", "shuffle": False},
+ ])
+ assert out["pos"] == 2, "the reshuffle jumped to the first copy of 'a'"
+ assert out["playing"] == "a"
+
+
+def test_reshuffling_an_empty_queue_does_not_throw(tmp_path, module_source):
+ out = _reduce(tmp_path, module_source, [
+ {"type": "reshuffle", "shuffle": True},
+ ])
+ assert out["play"] == []
+ assert out["pos"] == 0
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index c10f183..5c535a3 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -43,6 +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 / "auth-page.js", STATIC / "explore-page.js",
CREATE_GROUP]