aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-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
19 files changed, 685 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; }