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 };