import {
html, useState, useEffect, useMemo,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
import { Icon } from './icon.js';
import { MediaThumb, LazyTile } from './video-app.js';
import { formatTime } from './music-player.js';
// -- Music --------------------------------------------------------------------
//
// An album-grid (MusicBrainz-enriched, when a track has no usable embedded
// cover) or flat (tag/filename-only) browser for a group's audio files, per
// docs/musicbay.md. Grouping is by `artist`/`album` -- already resolved at
// index time from embedded tags, falling back to filename/folder parsing
// (indexer/enrich_audio.py) -- never guessed here.
//
// Unlike Videos, MusicBrainz is looked up only when a track has no embedded
// cover art at all (musicbay.md section 2.1's tiered trust: tags first,
// filename parsing second, MusicBrainz last) -- most of a real, well-ripped
// library already carries good artist/album text and often its own cover, so
// this avoids a network round trip most tiles never need. Playback never
// touches this file: clicking a track calls the `onPlayQueue` prop the shell
// (group-page.js) provides, which owns the persistent player bar
// (music-player.js) -- see that file for why it lives outside this one.
const VIEW_MODE_KEY = 'meshbay_music_view_mode';
function loadViewMode() {
try { return localStorage.getItem(VIEW_MODE_KEY) === 'flat' ? 'flat' : 'grid'; }
catch { return 'grid'; }
}
function saveViewMode(mode) {
try { localStorage.setItem(VIEW_MODE_KEY, mode); } catch { /* per-device convenience only */ }
}
// -- grouping -------------------------------------------------------------
// Two tags differing only in case (found live: a single mistagged track
// split one real album into two cards) are the same artist/album for
// grouping purposes. Same for an "&" vs "and" spelling of the same act,
// tagged both ways across different rips of the same catalogue. Folded for
// the *key* only; the first-seen spelling is kept as the display string, so
// this never rewrites anyone's tags.
function foldKey(s) {
return (s || '').trim().replace(/\s+/g, ' ').toLowerCase()
.replace(/\s*&\s*/g, ' and ').replace(/\s+/g, ' ').trim();
}
// Same shape as video-app.js's underVideoRoot: an unset root means "show
// nothing" (docs/musicbay.md's amended §2.1 — the node itself runs no
// tag/cover enrichment for this group before a root is chosen either,
// daemon.py's _enrich_new_audio_entries), not "the whole shared tree" —
// falling back to that would just show files nothing has enriched.
function underAudioRoot(entry, audioRoot) {
if (!audioRoot) return false;
const p = entry.path || '';
return p === audioRoot || p.startsWith(audioRoot + '/');
}
function groupMusicEntries(entries, audioRoot) {
const tracks = []; // no artist at all, even after the folder fallback -- rare, but real
const byArtistKey = new Map(); // foldKey(artist) -> { artist, albumsByKey: Map, loose: [] }
for (const e of entries) {
if (e.type !== 'audio') continue;
if (!underAudioRoot(e, audioRoot)) continue;
const artistRaw = (e.artist || '').trim();
if (!artistRaw) { tracks.push(e); continue; }
const artistKey = foldKey(artistRaw);
if (!byArtistKey.has(artistKey)) {
byArtistKey.set(artistKey, { artist: artistRaw, albumsByKey: new Map(), loose: [] });
}
const artistBucket = byArtistKey.get(artistKey);
const albumRaw = (e.album || '').trim();
if (!albumRaw) { artistBucket.loose.push(e); continue; }
const albumKey = foldKey(albumRaw);
if (!artistBucket.albumsByKey.has(albumKey)) {
artistBucket.albumsByKey.set(albumKey, {
artist: artistBucket.artist, album: albumRaw, isUnknown: false, tracks: [],
});
}
artistBucket.albumsByKey.get(albumKey).tracks.push(e);
}
const trackNo = (e) => (e.track_no == null ? 9999 : e.track_no);
const byTitle = (a, b) => (a.display_title || a.name).localeCompare(b.display_title || b.name);
tracks.sort(byTitle);
// A various-artists compilation (a real film/game soundtrack is the
// common shape: dozens of genuinely different per-track artists sharing
// one correctly-tagged album name, confirmed against a real ~20-track
// soundtrack rip with no separate "album artist" tag at all -- this era
// of rip never wrote one). Grouping by artist first, as above, can never
// recognize this: every track lands alone in its own artist's bucket as
// a one-track "album", each one then folded below into that artist's own
// singleton pile -- the same release rendered as a wall of disconnected
// one-track cards under a dozen different artist headings instead of one.
// Detected the only way the data actually supports here (no album-artist
// tag survived): the same album key reappears under two or more
// genuinely different artist keys. Pulled out and merged *before* the
// per-artist singleton folding below, so those tracks never reach it
// under their original artist bucket.
const albumKeyArtists = new Map(); // albumKey -> Set(artistKey)
for (const [artistKey, bucket] of byArtistKey) {
for (const albumKey of bucket.albumsByKey.keys()) {
if (!albumKeyArtists.has(albumKey)) albumKeyArtists.set(albumKey, new Set());
albumKeyArtists.get(albumKey).add(artistKey);
}
}
const compilations = [];
for (const [albumKey, artistKeys] of albumKeyArtists) {
if (artistKeys.size < 2) continue;
const compTracks = [];
let albumDisplay = null;
for (const artistKey of artistKeys) {
const bucket = byArtistKey.get(artistKey);
const album = bucket.albumsByKey.get(albumKey);
if (albumDisplay == null) albumDisplay = album.album;
compTracks.push(...album.tracks);
bucket.albumsByKey.delete(albumKey);
}
compTracks.sort((a, b) => (trackNo(a) - trackNo(b)) || byTitle(a, b));
compilations.push({
artist: t('music.various'), album: albumDisplay, isUnknown: false, tracks: compTracks,
});
}
compilations.sort((a, b) => a.album.localeCompare(b.album));
const artists = [...byArtistKey.values()].map(({ artist, albumsByKey, loose }) => {
const sortedAlbums = [...albumsByKey.values()].sort((a, b) => a.album.localeCompare(b.album));
// A "singleton" -- an album bucket down to exactly one track, because
// that's a real album tag but this person only has one song from it,
// not the whole release -- clutters the grid exactly the way an
// untagged loose track does. Found live: one artist's folder listing a
// dozen near-empty one-track album cards alongside the genuine
// multi-track albums. Both kinds fold into a single " -
// Various" tile, unless there is only one leftover track overall, where
// relabeling away a real album name (or minting "Various" for one
// file) buys nothing.
const realAlbums = [];
const misc = [...loose];
const miscSourceAlbums = [];
for (const album of sortedAlbums) {
if (album.tracks.length > 1) { realAlbums.push(album); continue; }
misc.push(...album.tracks);
miscSourceAlbums.push(album);
}
for (const album of realAlbums) {
album.tracks.sort((a, b) => (trackNo(a) - trackNo(b)) || byTitle(a, b));
}
if (misc.length === 1) {
// Keep the one leftover's own real album name if it had one; the
// generic placeholder only for a single untagged track with nothing
// else to call it.
realAlbums.push(miscSourceAlbums[0]
|| { artist, album: t('music.unknown_album'), isUnknown: true, tracks: misc });
} else if (misc.length > 1) {
misc.sort((a, b) => (trackNo(a) - trackNo(b)) || byTitle(a, b));
realAlbums.push({
artist, album: `${artist} - ${t('music.various')}`, isUnknown: true, tracks: misc,
});
}
return { artist, albums: realAlbums };
}).filter((a) => a.albums.length > 0);
if (compilations.length > 0) artists.push({ artist: t('music.various'), albums: compilations });
artists.sort((a, b) => a.artist.localeCompare(b.artist));
const albums = artists.flatMap((a) => a.albums);
return { tracks, artists, albums };
}
// -- album cover, MusicBrainz fetched lazily and only when actually needed --
const _musicMetaRetryListeners = new Set();
function bumpMusicMetaGeneration() {
for (const fn of _musicMetaRetryListeners) fn();
}
function useMusicMeta(transportRef, fileId, active) {
const [meta, setMeta] = useState(null);
const [retryToken, setRetryToken] = useState(0);
useEffect(() => {
const listener = () => setRetryToken((n) => n + 1);
_musicMetaRetryListeners.add(listener);
return () => _musicMetaRetryListeners.delete(listener);
}, []);
useEffect(() => {
if (!active || !fileId) return;
let cancelled = false;
(async () => {
const transport = transportRef.current;
if (!transport || !transport.connected) return;
try {
const resp = await transport.fetchMusicMeta(fileId);
if (!cancelled) setMeta(resp);
} catch { if (!cancelled) setMeta({ confidence: 0 }); }
})();
return () => { cancelled = true; };
}, [fileId, active, retryToken]);
return meta;
}
// A drawn CD standing in for a cover nothing supplied one for -- most tiles
// in a real, older/well-ripped library land here (musicbay.md's own
// measurement: ~11% embedded art, ~26% once sibling image files are counted
// too), so this is the *default* look of the grid, not a rare fallback, and
// needed to read as a deliberate piece of art rather than a broken image.
// A flat single-color icon (the first version of this) looked exactly like
// "missing", not "no cover" -- an actual disc, with the iridescent sheen a
// real CD's data side has, reads as intentional at a glance. Genuinely
// unique gradient ids: a `` id is a plain DOM id, and a grid
// full of these renders many instances at once -- reusing one literal id
// would leave every disc after the first pointing at whichever def the
// browser resolves first.
let _discIdSeq = 0;
function DiscPlaceholder({ cls }) {
const [gradId] = useState(() => `music-disc-sheen-${_discIdSeq++}`);
return html`
`)}
${detail && html`
<${MusicDetailModal} album=${detail} transportRef=${transportRef} gekRef=${gekRef}
musicbrainzEnabled=${musicbrainzEnabled}
onClose=${() => setDetail(null)} onPlayQueue=${onPlayQueue} />
`}
`;
}
// -- Mode B: flat, folder-based, no MusicBrainz ------------------------------
// A track is not a folder — it was rendered with the same boxy thumbnail
// slot as one anyway (borrowed wholesale from Videos' flat list), which
// meant a big album unfolded into a wall of identical little squares, one
// per row, carrying no information (no embedded-art-in-a-list-row concept
// exists here, unlike Videos' per-episode thumbnail). Reuses the plain
// numbered-row style Mode A's own tracklist already uses instead
// (music-track-row) — track number, title, duration, no icon box.
function FlatTrackRow({ track, index, onPlay }) {
const num = track.track_no || (index != null ? index + 1 : null);
return html`
`;
}
function FlatAlbumFolder({ album, onPlayQueue }) {
const [open, setOpen] = useState(false);
return html`
${/* A real artist folder with no album layer at all is common here
-- a pile of loose singles, not one release (musicbay.md
section 2.1's "flat per-artist folder" case). Nesting them
one more level behind their own always-empty "Unknown album"
row was exactly the friction reported live: an extra,
pointless expand before reaching a track that's playable
(with full previous/next across the whole pile --
onPlayQueue already gets every track sharing this bucket) at
all. A real, named album still gets its own foldable row,
one indent level deeper than its loose siblings would be. */
artist.albums.map((album) => (album.isUnknown
? album.tracks.map((tr, i) => html`
<${FlatTrackRow} key=${tr.id} track=${tr} index=${i}
onPlay=${() => onPlayQueue(album.tracks, i)} />
`)
: html`<${FlatAlbumFolder} key=${album.album} album=${album} onPlayQueue=${onPlayQueue} />`
))}