aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 17:39:41 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 17:39:41 +0200
commit8c780b9928db8145b7fe18ecd137384ccfe25d8f (patch)
tree5cfc06eec0fdf9bb7fe0a14f45e3ef803bb86a20 /packages/meshbay-hub/src/meshbay_hub/static/music-player.js
parent941d1a135dd7b03834576855e8e9fdaa24c4e406 (diff)
downloadmeshbay-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.js320
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 };