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 */ } } // The current queue, tracklist form -- "go back to what's playing" without // switching tabs or hunting for the album/folder it came from. Works the // same regardless of how the queue was built (an album, a consolidated // misc/loose bucket, a single standalone track). function QueuePanel({ tracks, order, pos, onSelect, onClose }) { return html`
{ if (e.target.classList.contains('video-overlay')) onClose(); }}>
${t('music.queue_title')}
${order.map((idx, i) => html` `)}
`; } function MusicPlayerBar({ transportRef, gekRef, queue, onClose }) { 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 [showQueue, setShowQueue] = useState(false); // A broken source file (truncated download, a format the browser has no // decoder for) must not stall a "play all" queue on the one track that // failed — found live against a real library: a corrupt few-hundred-byte // file with no audio stream at all, sitting between two good tracks. // Bounded so a queue that turns out to be *entirely* bad (every file the // same unsupported format) fails once, visibly, rather than burning // through the whole list in an instant. const consecutiveFailuresRef = useRef(0); const MAX_CONSECUTIVE_FAILURES = 5; const currentTrack = tracks[order[pos]] || null; const advancePastFailure = useCallback(() => { consecutiveFailuresRef.current += 1; if (consecutiveFailuresRef.current > MAX_CONSECUTIVE_FAILURES || order.length <= 1) return; if (pos + 1 < order.length) setPos(pos + 1); else if (repeat === 'all') setPos(0); }, [order.length, pos, repeat]); // Stops playback the moment this bar goes away for any reason -- the // close button below, or the shell tearing it down on its own (leaving // the group, switching to a different one). A component removed from the // DOM should already stop an