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`
`; } function AlbumCard({ album, transportRef, gekRef, musicbrainzEnabled, onOpen }) { const repTrack = album.tracks.find((tr) => tr.thumb_hash) || album.tracks[0]; const tRef = repTrack._tRef || transportRef; const gRef = repTrack._gRef || gekRef; const needsLookup = musicbrainzEnabled && !repTrack.thumb_hash; const meta = useMusicMeta(tRef, repTrack.id, needsLookup); const coverHash = repTrack.thumb_hash || (meta && meta.cover_thumb_hash) || null; return html`
${coverHash ? html`<${MediaThumb} thumbHash=${coverHash} alt=${album.album} cls="music-cover" transportRef=${tRef} gekRef=${gRef} />` : html`<${DiscPlaceholder} cls="music-cover" />`}
${album.album}
${album.artist}
${repTrack.groupName && html`
${repTrack.groupName}
`}
`; } // -- detail modal: tracklist + play/play-all ------------------------------- function MusicDetailModal({ album, transportRef, gekRef, musicbrainzEnabled, onClose, onPlayQueue }) { const repTrack = album.tracks.find((tr) => tr.thumb_hash) || album.tracks[0]; const tRef = repTrack._tRef || transportRef; const gRef = repTrack._gRef || gekRef; const needsLookup = musicbrainzEnabled && !repTrack.thumb_hash; const meta = useMusicMeta(tRef, repTrack.id, needsLookup); const coverHash = repTrack.thumb_hash || (meta && meta.cover_thumb_hash) || null; return html`
{ if (e.target.classList.contains('video-overlay')) onClose(); }}>
${album.album}
${coverHash ? html`<${MediaThumb} thumbHash=${coverHash} alt=${album.album} cls="music-detail-cover" transportRef=${tRef} gekRef=${gRef} />` : html`<${DiscPlaceholder} cls="music-detail-cover" />`}
${album.artist}
${meta && meta.confidence ? html`
${meta.release_date || ''}
` : ''}
${album.tracks.map((tr, i) => html` `)}
`; } // -- Mode A: album grid ----------------------------------------------------- function AlbumGrid({ artists, transportRef, gekRef, musicbrainzEnabled, onPlayQueue }) { const [detail, setDetail] = useState(null); // the album object return html` ${artists.map((a) => html`

${a.artist}

${a.albums.map((album) => html` <${LazyTile} key=${album.artist + '::' + album.album} cls="music-tile-slot"> <${AlbumCard} album=${album} transportRef=${transportRef} gekRef=${gekRef} musicbrainzEnabled=${musicbrainzEnabled} onOpen=${() => setDetail(album)} /> `)}
`)} ${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`
setOpen((v) => !v)}>
<${Icon} name="folder" />
${album.album}
${t('music.n_tracks', { n: album.tracks.length })}
<${Icon} name="chevron" cls=${open ? 'video-flat-chevron open' : 'video-flat-chevron'} />
${open && html`
${album.tracks.map((tr, i) => html` <${FlatTrackRow} key=${tr.id} track=${tr} index=${i} onPlay=${() => onPlayQueue(album.tracks, i)} /> `)}
`}
`; } function FlatArtistFolder({ artist, onPlayQueue }) { const [open, setOpen] = useState(false); const singleAlbum = artist.albums.length === 1 ? artist.albums[0] : null; const trackCount = artist.albums.reduce((n, a) => n + a.tracks.length, 0); return html`
setOpen((v) => !v)}>
<${Icon} name="folder" />
${artist.artist}
${singleAlbum && !singleAlbum.isUnknown ? singleAlbum.album : t('music.n_tracks', { n: trackCount })}
<${Icon} name="chevron" cls=${open ? 'video-flat-chevron open' : 'video-flat-chevron'} />
${open && 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} />` ))}
`}
`; } function FlatList({ tracks, artists, onPlayQueue }) { const items = [ ...tracks.map((tr) => ({ key: tr.display_title || tr.name, kind: 'track', track: tr })), ...artists.map((a) => ({ key: a.artist, kind: 'artist', artist: a })), ].sort((a, b) => a.key.localeCompare(b.key)); return html`
${items.map((it) => it.kind === 'track' ? html`<${FlatTrackRow} key=${it.track.id} track=${it.track} onPlay=${() => onPlayQueue([it.track], 0)} />` : html`<${FlatArtistFolder} key=${it.artist.artist} artist=${it.artist} onPlayQueue=${onPlayQueue} />`)}
`; } // -- shell -------------------------------------------------------------------- function MusicApp({ groupId, transportRef, gekRef, status, entries, audioRoot, musicbrainzConfig, onPlayQueue, hideFilter, }) { const [mode, setMode] = useState(loadViewMode); const [filter, setFilter] = useState(''); const musicbrainzEnabled = musicbrainzConfig ? musicbrainzConfig.enabled : true; useEffect(() => { setMode(loadViewMode()); }, [groupId]); useEffect(() => { setFilter(''); }, [groupId]); const setModeAndSave = (m) => { setMode(m); saveViewMode(m); }; const { tracks, artists, albums } = useMemo( () => groupMusicEntries(entries, audioRoot), [entries, audioRoot]); const needle = filter.trim().toLowerCase(); const filteredArtists = useMemo(() => { if (!needle) return artists; return artists .map((a) => ({ artist: a.artist, albums: a.albums.filter((al) => al.album.toLowerCase().includes(needle) || a.artist.toLowerCase().includes(needle)), })) .filter((a) => a.albums.length > 0 || a.artist.toLowerCase().includes(needle)); }, [artists, needle]); const filteredTracks = useMemo(() => (!needle ? tracks : tracks.filter( (tr) => (tr.display_title || tr.name).toLowerCase().includes(needle))), [tracks, needle]); const empty = albums.length === 0 && tracks.length === 0; return html` ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`

${' '}${t('status.connecting_short')}

`} ${status === 'offline' && html`

${t('group.offline_title')} ${t('group.offline_hint')}

`} ${status === 'connected' && !audioRoot && html`

${t('music.no_root_configured')}

`} ${status === 'connected' && audioRoot && html`
${!hideFilter && html``}
${empty && html`

${t('music.empty')}

`} ${!empty && needle && filteredArtists.length === 0 && filteredTracks.length === 0 && html`

${t('group.empty_filter')}

`} ${!empty && mode === 'grid' ? html`<${AlbumGrid} artists=${filteredArtists} transportRef=${transportRef} gekRef=${gekRef} musicbrainzEnabled=${musicbrainzEnabled} onPlayQueue=${onPlayQueue} />` : !empty && html`<${FlatList} tracks=${filteredTracks} artists=${filteredArtists} onPlayQueue=${onPlayQueue} />`} `} `; } export { MusicApp, groupMusicEntries, bumpMusicMetaGeneration };