diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 11:01:02 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 11:01:02 +0200 |
| commit | 20706fb9a4ec646816b44a10842aa8f58ea0fd75 (patch) | |
| tree | 126be97d794491196c6033ae76d4680f24188595 /packages/meshbay-hub/src/meshbay_hub/static/music-app.js | |
| parent | a79a38a22a6145c475f50eeadb79b451aee31c11 (diff) | |
| download | meshbay-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.js | 135 |
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} />`} `; } |