From 8c780b9928db8145b7fe18ecd137384ccfe25d8f Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 24 Aug 2026 17:39:41 +0200 Subject: feat(hub): Music app client — album grid, flat list, persistent player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy --- .../src/meshbay_hub/static/music-player.js | 320 +++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/music-player.js (limited to 'packages/meshbay-hub/src/meshbay_hub/static/music-player.js') 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` +
+
+ `; +} + +export { MusicPlayerBar, formatTime }; -- cgit v1.2.3