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