aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
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/meshbay-hub/src/meshbay_hub/static/music-app.js
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/meshbay-hub/src/meshbay_hub/static/music-app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js135
1 files changed, 98 insertions, 37 deletions
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} />`}
`;
}