diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-24 17:39:41 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-24 17:39:41 +0200 |
| commit | 8c780b9928db8145b7fe18ecd137384ccfe25d8f (patch) | |
| tree | 5cfc06eec0fdf9bb7fe0a14f45e3ef803bb86a20 /packages/meshbay-hub/src/meshbay_hub/static/music-player.js | |
| parent | 941d1a135dd7b03834576855e8e9fdaa24c4e406 (diff) | |
| download | meshbay-8c780b9928db8145b7fe18ecd137384ccfe25d8f.tar.gz | |
feat(hub): Music app client — album grid, flat list, persistent player
Implements the client half of docs/musicbay.md against MNP 0.8:
- music-app.js: album grid (grouped by artist -> album, from index-time
artist/album fields) or flat folder view, per-group localStorage
toggle like Videos. MusicBrainz (music_meta_req) is only looked up
when a track has no embedded cover at all — well-tagged files never
trigger a network call, unlike Videos where TMDB is unconditional.
Reuses video-app.js's MediaThumb/LazyTile (now exported) rather than
duplicating the chunk-path thumbnail decode + virtualization.
- music-player.js: the persistent player bar — queue, shuffle (Fisher-
Yates, keeps the current track in place when toggled), repeat (off/
all/one), volume (localStorage), prev/next, a one-track prefetch
cache. No MSE, no node-side streaming: a track is downloaded and
decrypted once via file-utils.js's pipelinedDownload, same chunk
pipeline Files already uses, then played from a blob URL.
- group-page.js: owns musicQueue/musicbrainzConfig state and renders
MusicPlayerBar outside the tab-switched area — deliberately, so
playback survives navigating to Chat/Files, the same reasoning the
video/preview modals are shell-owned rather than app-owned.
- apps.js: registers "music". transport.js: fetchMusicMeta (keyed by
path, same reordering-hazard fix as fetchMediaMeta),
setMusicbrainzConfig/setMusicbrainzEnabled (signed ops, mirroring
TMDB's), and the three new ack handlers. group-settings.js: a
MusicBrainz settings section (contact string, per-group toggle) —
the existing Applications checklist already picks up "music" for
free, per apps.md's own claim.
- icon.js: music/pause/skip-next/skip-prev/shuffle/repeat/volume,
drawn in the same stroked style as the existing set.
- i18n: group.tab_music, the music.* and settings_node.musicbrainz_*
keys, translated (not just copied) across all ten locales, Polish
carrying full one/few/many/other plural forms for music.n_tracks.
- webapp.py's _ASSETS, test_hook_ordering.py's STATIC_FILES and
test_transport_contracts.py's SPLIT_FILES gain the two new files.
Full suite (common + hub + node): 1116 passed, no regressions.
`npm run sync-ui` in meshbay-client confirmed both files copied.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/music-player.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/music-player.js | 320 |
1 files changed, 320 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js new file mode 100644 index 0000000..386d3f6 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js @@ -0,0 +1,320 @@ +import { + html, useState, useEffect, useRef, useCallback, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { CHUNK_SIZE, pipelinedDownload } from './file-utils.js'; + +/** + * The Music app's persistent player bar (docs/musicbay.md §2.3, §7.2). + * + * Owned and rendered by group-page.js, *not* by music-app.js: it is the one + * piece of this feature that lives outside the tab-switched area, so + * playback survives navigating to Chat or Files, exactly the way the + * video/preview modals are shell-owned rather than owned by whichever app + * opened them. music-app.js never touches audio state directly — it only + * calls `onPlayQueue(tracks, startIndex)`, threaded down from group-page.js, + * to hand this component a new queue. + * + * No streaming, no MSE, no node-side transcode pool: a track is a few + * megabytes, so it is downloaded and decrypted once through the same chunk + * pipeline Files already uses (file-utils.js's pipelinedDownload), then + * played from a blob URL — the deliberate simplification recorded in + * musicbay.md §2.2. + */ + +const MIME_BY_EXT = { + mp3: 'audio/mpeg', flac: 'audio/flac', ogg: 'audio/ogg', opus: 'audio/opus', + wav: 'audio/wav', aac: 'audio/aac', m4a: 'audio/mp4', +}; + +function guessMime(name) { + const ext = (name || '').split('.').pop().toLowerCase(); + return MIME_BY_EXT[ext] || 'audio/mpeg'; +} + +function formatTime(seconds) { + if (!isFinite(seconds) || seconds < 0) return '0:00'; + const total = Math.floor(seconds); + const m = Math.floor(total / 60); + const s = total % 60; + 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 a one-track read-ahead 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. +const MAX_CACHED_BLOBS = 3; + +function loadVolume() { + try { + const v = parseFloat(localStorage.getItem('meshbay_music_volume')); + return isFinite(v) && v >= 0 && v <= 1 ? v : 1; + } catch { return 1; } +} +function saveVolume(v) { + try { localStorage.setItem('meshbay_music_volume', String(v)); } catch { /* per-device only */ } +} +function loadShuffle() { + try { return localStorage.getItem('meshbay_music_shuffle') === '1'; } catch { return false; } +} +function saveShuffle(v) { + try { localStorage.setItem('meshbay_music_shuffle', v ? '1' : '0'); } catch { /* per-device only */ } +} +function loadRepeat() { + try { + const v = localStorage.getItem('meshbay_music_repeat'); + return v === 'all' || v === 'one' ? v : 'off'; + } catch { return 'off'; } +} +function saveRepeat(v) { + try { localStorage.setItem('meshbay_music_repeat', v); } catch { /* per-device only */ } +} + +function MusicPlayerBar({ transportRef, gekRef, queue }) { + const audioRef = useRef(null); + const blobCacheRef = useRef(new Map()); // file id -> { url, order: insertion index } + const blobInsertRef = useRef(0); + const loadTokenRef = useRef(0); + + const [tracks, setTracks] = useState([]); + const [order, setOrder] = useState([]); + const [pos, setPos] = useState(0); // index into `order` + const [playing, setPlaying] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [shuffle, setShuffle] = useState(loadShuffle); + const [repeat, setRepeat] = useState(loadRepeat); // 'off' | 'all' | 'one' + const [volume, setVolume] = useState(loadVolume); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + + const currentTrack = tracks[order[pos]] || null; + + const evictOldBlobs = useCallback(() => { + const cache = blobCacheRef.current; + while (cache.size > MAX_CACHED_BLOBS) { + let oldestId = null, oldestOrder = Infinity; + for (const [id, v] of cache) { + if (v.order < oldestOrder) { oldestOrder = v.order; oldestId = id; } + } + if (oldestId == null) break; + URL.revokeObjectURL(cache.get(oldestId).url); + cache.delete(oldestId); + } + }, []); + + const fetchTrackBlob = useCallback(async (entry) => { + const cached = blobCacheRef.current.get(entry.id); + if (cached) return cached.url; + const transport = transportRef.current; + if (!transport || !transport.connected) throw new Error(t('music.err_transport')); + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + const chunks = await pipelinedDownload(transport, gekRef.current, entry.id, totalChunks); + const blob = new Blob(chunks, { type: guessMime(entry.name) }); + const url = URL.createObjectURL(blob); + blobCacheRef.current.set(entry.id, { url, order: blobInsertRef.current++ }); + evictOldBlobs(); + return url; + }, [transportRef, gekRef, evictOldBlobs]); + + // Silently warms the cache for the next track so pressing "next" doesn't + // visibly wait (musicbay.md §2.2) — best-effort, never surfaces an error. + const prefetchNext = useCallback((fromPos) => { + const nextEntry = tracks[order[fromPos + 1]]; + if (!nextEntry || blobCacheRef.current.has(nextEntry.id)) return; + fetchTrackBlob(nextEntry).catch(() => {}); + }, [tracks, order, fetchTrackBlob]); + + // (Re)initialize the queue whenever the shell hands over a new one. + // `queue.nonce` changes on every call to onPlayQueue, including "play the + // same album again from track 0" — a fresh Date.now() each time, so this + // 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(''); + // Playback itself starts from the effect below, keyed on [tracks, order, pos]. + }, [queue]); + + // 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. + useEffect(() => { + if (!currentTrack) return; + const myToken = ++loadTokenRef.current; + setLoading(true); + setError(''); + (async () => { + try { + const url = await fetchTrackBlob(currentTrack); + if (loadTokenRef.current !== myToken) return; // superseded by a newer skip + const audio = audioRef.current; + if (!audio) return; + audio.src = url; + audio.currentTime = 0; + await audio.play(); + setPlaying(true); + prefetchNext(pos); + } catch (err) { + if (loadTokenRef.current !== myToken) return; + setError(err.message || String(err)); + setPlaying(false); + } finally { + if (loadTokenRef.current === myToken) setLoading(false); + } + })(); + // eslint-disable-next-line + }, [currentTrack && currentTrack.id]); + + useEffect(() => { + const audio = audioRef.current; + if (audio) audio.volume = volume; + saveVolume(volume); + }, [volume]); + + const skipTo = useCallback((newPos) => setPos(newPos), []); + + const skipNext = useCallback(() => { + if (order.length === 0) return; + if (pos + 1 < order.length) { skipTo(pos + 1); return; } + if (repeat === 'all') { skipTo(0); return; } + setPlaying(false); // end of queue, nothing to repeat + }, [pos, order.length, repeat, skipTo]); + + const skipPrev = useCallback(() => { + if (order.length === 0) return; + // A few seconds in: restart the current track, the way most players do, + // rather than always jumping to the previous one. + const audio = audioRef.current; + if (audio && audio.currentTime > 3) { audio.currentTime = 0; return; } + if (pos > 0) { skipTo(pos - 1); return; } + if (repeat === 'all') { skipTo(order.length - 1); } + }, [pos, order.length, repeat, skipTo]); + + const onEnded = useCallback(() => { + if (repeat === 'one') { + const audio = audioRef.current; + if (audio) { audio.currentTime = 0; audio.play().catch(() => {}); } + return; + } + skipNext(); + }, [repeat, skipNext]); + + const togglePlaying = useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + if (playing) { audio.pause(); setPlaying(false); } + else { audio.play().then(() => setPlaying(true)).catch(() => {}); } + }, [playing]); + + 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 cycleRepeat = useCallback(() => { + setRepeat((prev) => { + const next = prev === 'off' ? 'all' : prev === 'all' ? 'one' : 'off'; + saveRepeat(next); + return next; + }); + }, []); + + const seek = useCallback((e) => { + const audio = audioRef.current; + if (audio && isFinite(audio.duration)) audio.currentTime = parseFloat(e.target.value); + }, []); + + if (!currentTrack) return null; + + const title = currentTrack.display_title || currentTrack.name; + const repeatLabel = repeat === 'off' ? t('music.player_repeat_off') + : repeat === 'all' ? t('music.player_repeat_all') : t('music.player_repeat_one'); + + return html` + <div class="music-player-bar"> + <audio ref=${audioRef} + onTimeUpdate=${(e) => setCurrentTime(e.target.currentTime)} + onDurationChange=${(e) => setDuration(e.target.duration)} + onEnded=${onEnded} /> + <div class="music-player-info"> + <${Icon} name="music" cls="music-player-icon" /> + <div class="music-player-text"> + <div class="music-player-title">${title}</div> + <div class="music-player-sub"> + ${[currentTrack.artist, currentTrack.album].filter(Boolean).join(' · ')} + </div> + </div> + </div> + <div class="music-player-transport"> + <button class="music-player-btn ${shuffle ? 'active' : ''}" + title=${t('music.player_shuffle')} onClick=${toggleShuffle}> + <${Icon} name="shuffle" /> + </button> + <button class="music-player-btn" title=${t('music.player_prev')} onClick=${skipPrev}> + <${Icon} name="skip-prev" /> + </button> + <button class="music-player-btn music-player-play" title=${playing ? t('music.player_pause') : t('music.player_play')} + onClick=${togglePlaying} disabled=${loading}> + ${loading ? html`<span class="spinner"></span>` : html`<${Icon} name=${playing ? 'pause' : 'play'} />`} + </button> + <button class="music-player-btn" title=${t('music.player_next')} onClick=${skipNext}> + <${Icon} name="skip-next" /> + </button> + <button class="music-player-btn ${repeat !== 'off' ? 'active' : ''} music-player-repeat-${repeat}" + title=${repeatLabel} onClick=${cycleRepeat}> + <${Icon} name="repeat" /> + ${repeat === 'one' && html`<span class="music-repeat-badge">1</span>`} + </button> + </div> + <div class="music-player-seek"> + <span class="music-player-time">${formatTime(currentTime)}</span> + <input type="range" min="0" max=${duration || 0} step="1" value=${currentTime} + disabled=${!duration} onInput=${seek} /> + <span class="music-player-time">${formatTime(duration)}</span> + </div> + <div class="music-player-volume"> + <${Icon} name="volume" /> + <input type="range" min="0" max="1" step="0.05" value=${volume} + title=${t('music.player_volume')} + onInput=${(e) => setVolume(parseFloat(e.target.value))} /> + </div> + ${error && html`<div class="music-player-error">${error}</div>`} + </div> + `; +} + +export { MusicPlayerBar, formatTime }; |