import {
html, useState, useEffect, useMemo, useCallback,
} 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';
import { SourceTag } from './group-name.js';
import { usePager, Pager, pageSizeFrom } from './pager.js';
import { Menu, MenuDots, useMenu } from './menu.js';
import { PlaylistMenuButton, NameModal, usePlaylists } from './playlist-menu.js';
import * as P from './playlists.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/MESHBAY_DESIGN.md §9.8. 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 (docs/MESHBAY_DESIGN.md §9.8's order of 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/MESHBAY_DESIGN.md §9.8 — the node itself runs no
// tag/cover enrichment for this group before a root is chosen either,
// enrichment.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, directories) {
const dirs = directories || [];
if (!dirs.length) return false;
const p = entry.path || '';
return dirs.some((d) => p === d || p.startsWith(d + '/'));
}
function groupMusicEntries(entries, musicDirectories) {
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, musicDirectories)) 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 (measured: ~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, onMenu }) {
const repTrack = album.tracks.find((tr) => tr.thumb_hash) || album.tracks[0];
const tRef = repTrack._tRef || transportRef;
const gRef = repTrack._gRef || gekRef;
// Never for an album this file minted itself (`isUnknown`): the release
// name is one we wrote -- ' - Various', or the untagged pile
// below -- so the lookup is a third-party request, on the operator's
// connection, that cannot match anything. Confirmed in a node's log:
// queries went out naming a placeholder as both the artist and the
// release, once per card.
const needsLookup = musicbrainzEnabled && !repTrack.thumb_hash && !album.isUnknown;
const meta = useMusicMeta(tRef, repTrack.id, needsLookup);
const coverHash = repTrack.thumb_hash || (meta && meta.cover_thumb_hash) || null;
// Right-click the tile, or press the dots over its cover: the same menu,
// because neither affordance covers everyone (menu.js).
const openMenu = (e) => onMenu(e, album.tracks, 0);
return html`
${/* A row carries two actions now — play it, and open its menu —
and a button cannot contain another button: the browser
reparents the inner one and the row comes apart. The row is a
div holding both. */
album.tracks.map((tr, i) => html`
`;
}
// -- Mode A: album grid -----------------------------------------------------
/**
* One page of `{ artist, album }` as the sections that get drawn.
*
* An artist with two or more albums gets a heading and a grid of their own. An
* artist with **one** does not: a heading plus a single cover is a whole row of
* whitespace, and a library is mostly single-album artists — a compilation
* bought once, one album of someone's, a soundtrack. Consecutive singles share
* one grid instead, so five of them fill a row that five headings would
* otherwise have spent five rows on.
*
* Pooled in place rather than swept into a bin at the end: the page is drawn in
* artist order and a reader scrolling it is relying on that. A pooled run sits
* exactly where its artists would have been.
*
* **Each pooled cover keeps its artist's name above it, in the same type as a
* multi-album artist's heading.** The first version of this dropped the heading
* on the grounds that the card already names its artist underneath — and that
* was wrong: scrolling then alternates between artists written large and
* artists written small, and the eye has to work out which kind of row it is
* looking at. The heading moves *into* the cell rather than going away, so a
* row of five costs one heading's height between them instead of five rows.
*/
function albumSections(units) {
const artists = [];
for (const u of units) {
const prev = artists[artists.length - 1];
if (prev && prev.artist === u.artist) prev.albums.push(u.album);
else artists.push({ artist: u.artist, albums: [u.album] });
}
const sections = [];
for (const a of artists) {
if (a.albums.length >= 2) { sections.push({ kind: 'artist', ...a }); continue; }
const prev = sections[sections.length - 1];
if (prev && prev.kind === 'pool') prev.albums.push(a.albums[0]);
else sections.push({ kind: 'pool', albums: [a.albums[0]] });
}
return sections;
}
// `units` is one page of `{ artist, album }`. An artist whose albums straddle
// two pages gets its heading on both — and, with one album on each page, is
// pooled on both.
function AlbumGrid({ units, transportRef, gekRef, musicbrainzEnabled, onPlayQueue, onMenu }) {
const [detail, setDetail] = useState(null); // the album object
const sections = useMemo(() => albumSections(units), [units]);
const tile = (album) => html`
<${LazyTile} key=${album.artist + '::' + album.album} cls="music-tile-slot">
<${AlbumCard} album=${album} transportRef=${transportRef} gekRef=${gekRef}
musicbrainzEnabled=${musicbrainzEnabled} onOpen=${() => setDetail(album)}
onMenu=${onMenu} />
${LazyTile}>
`;
// Keyed on the first album rather than on the index: a pool's position shifts
// whenever a neighbouring artist gains or loses an album, and an index key
// would make preact reuse the wrong tiles across that change.
const sectionKey = (s) => (s.kind === 'artist'
? `artist:${s.artist}`
: `pool:${s.albums[0].artist}::${s.albums[0].album}`);
return html`
${sections.map((s) => (s.kind === 'artist'
? html`
${s.artist}
${s.albums.map(tile)}
`
: html`
${s.albums.map((album) => html`
${/* One line, clipped, with the full name on hover: a cell is
~170px wide and a heading that wraps to two lines would
push its own cover below the others on the row. */''}
${album.artist}
${tile(album)}
`)}
`))}
${detail && html`
<${MusicDetailModal} album=${detail} transportRef=${transportRef} gekRef=${gekRef}
musicbrainzEnabled=${musicbrainzEnabled} onMenu=${onMenu}
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, onMenu }) {
const num = track.track_no || (index != null ? index + 1 : null);
const openMenu = (e) => onMenu(e, [track], 0);
return html`
`;
}
function FlatArtistFolder({ artist, onPlayQueue, onMenu }) {
const [open, setOpen] = useState(false);
const singleAlbum = artist.albums.length === 1 ? artist.albums[0] : null;
// Every track under this artist, albums and loose singles alike, in the
// order they are drawn — what "play this artist" has to mean.
const allTracks = artist.albums.flatMap((a) => a.tracks);
const trackCount = allTracks.length;
return html`
${/* A real artist folder with no album layer at all is common here
-- a pile of loose singles, not one release (a flat
per-artist folder, docs/MESHBAY_DESIGN.md §9.8). 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} onMenu=${onMenu}
onPlay=${() => onPlayQueue(album.tracks, i)} />
`)
: html`<${FlatAlbumFolder} key=${album.album} album=${album}
onPlayQueue=${onPlayQueue} onMenu=${onMenu} />`
))}
`}
`;
}
// `items` is one page of rows, already sorted by MusicApp.
function FlatList({ items, onPlayQueue, onMenu }) {
return html`
`;
}
// -- shell --------------------------------------------------------------------
function MusicApp({
groupId, transportRef, gekRef, status, entries, availableEntries,
musicDirectories, musicbrainzConfig, onPlayQueue, userId,
hideFilter, userPrefs, pageResetKey,
}) {
const [mode, setMode] = useState(loadViewMode);
const [filter, setFilter] = useState('');
const musicbrainzEnabled = musicbrainzConfig ? musicbrainzConfig.enabled : true;
// One menu for the whole view. Per-card state would mean a hundred open
// handlers on a full grid, and two menus could be open at once.
const { menu, openAt, close: closeMenu } = useMenu();
// One list, shared by the toolbar button and the per-item submenu below.
const { lists: playlists, reload: reloadPlaylists } = usePlaylists(userId);
const [pendingAdd, setPendingAdd] = useState(null);
const [note, setNote] = useState('');
const say = useCallback((text) => {
setNote(text);
setTimeout(() => setNote(''), 4000);
}, []);
// Sync rides the connection this group already has open — §7's whole point
// is that playlists add no dialing. Once per group opened, and again only
// when the reader asks.
const syncNow = useCallback(async () => {
const tr = transportRef && transportRef.current;
if (!tr || !userId) return { ok: false, reason: 'offline' };
const r = await P.syncWith(tr, userId);
await reloadPlaylists();
return r;
}, [transportRef, userId, reloadPlaylists]);
useEffect(() => {
if (status !== 'connected' || !userId) return;
syncNow().catch(() => {});
}, [status, userId, groupId]);
const addToPlaylist = useCallback(async (id, tracks) => {
const added = await P.addTracks(userId, id, tracks, groupId, t('playlists.favorites'));
await reloadPlaylists();
say(added
? t('playlists.added', { n: added })
: t('playlists.already_there'));
// Straight on to whatever node this group is on, so the edit is not only
// in this browser. Best-effort: it is durable locally either way.
syncNow().catch(() => {});
}, [userId, groupId, reloadPlaylists, say, syncNow]);
// The queue verbs, for an album (every track, from the first) or for one
// track. `startIndex` only means anything to "play": the other two do not
// have a place to start from, they have a place to go.
// `onPlayQueue(tracks, startIndex, op)` — three arguments, never four. The
// page that owns this view adds the group and its transport before passing
// it on to the shell; a view has no `source` to give and must not invent an
// argument slot for one.
const onMenu = useCallback((e, tracks, startIndex) => {
if (!tracks || !tracks.length) return;
openAt(e, [
{ label: t('music.menu_play'), icon: 'play',
onSelect: () => onPlayQueue(tracks, startIndex || 0) },
{ label: t('music.menu_play_next'), icon: 'playnext',
onSelect: () => onPlayQueue(tracks, 0, 'next') },
{ label: t('music.menu_enqueue'), icon: 'plus',
onSelect: () => onPlayQueue(tracks, 0, 'append') },
{ divider: true },
{
label: t('playlists.add_to'), icon: 'playlist',
// Drawn from the manifest, so it opens instantly with every node
// offline. Favourites is first, and is there on a fresh account
// because `livePlaylists` puts the reserved id first whether or not
// it has been used yet.
items: [
...(playlists.some((p) => p.id === P.FAVORITES_ID) ? [] : [{
key: P.FAVORITES_ID, label: t('playlists.favorites'), icon: 'check',
onSelect: () => addToPlaylist(P.FAVORITES_ID, tracks),
}]),
...playlists.map((p) => ({
key: p.id,
label: p.id === P.FAVORITES_ID ? t('playlists.favorites') : p.name,
hint: t('music.n_tracks', { n: p.count || 0 }),
onSelect: () => addToPlaylist(p.id, tracks),
})),
{ divider: true },
{
label: t('playlists.create'), icon: 'plus',
onSelect: () => setPendingAdd(tracks),
},
],
},
]);
}, [openAt, onPlayQueue, playlists, addToPlaylist]);
useEffect(() => { setMode(loadViewMode()); }, [groupId]);
useEffect(() => { setFilter(''); }, [groupId]);
const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
const musicEntries = availableEntries || entries;
const configured = (musicDirectories || []).length > 0;
const { tracks, artists, albums } = useMemo(
() => groupMusicEntries(musicEntries, musicDirectories),
[musicEntries, musicDirectories]);
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]);
// A track whose artist tag is empty *and* whose folder gave nothing to fall
// back on. The flat list has always drawn these as its own top-level rows;
// the grid, whose unit is an album, drew them nowhere at all -- and `empty`
// below counts them, so it stayed false and no message appeared either. A
// library nothing has tagged therefore rendered a toolbar over a blank page,
// with every one of its tracks one mode-switch away and nothing saying so.
// Reported after a node restart, where the index is briefly served without
// the tags it re-reads at start-up, and true of a genuinely untagged library
// with no restart involved.
//
// One card, the same shape the singleton folding above already mints for an
// artist's leftovers: it names what it is, and the tracks are playable from
// it. Not a card each -- that is the wall of one-track tiles this file
// exists to avoid -- and not sorted in among the artists, because it is not
// a name anybody chose and the alphabet is no place for it.
const untagged = useMemo(() => (filteredTracks.length
? { artist: t('music.unknown_artist'), album: t('music.unknown_album'),
isUnknown: true, tracks: filteredTracks }
: null), [filteredTracks]);
// What this mode draws, in drawing order: albums under their artists in the
// grid, and in the flat list loose tracks and artist folders sorted together.
const units = useMemo(() => {
if (mode === 'grid') {
const byArtist = filteredArtists.flatMap(
(a) => a.albums.map((album) => ({ artist: a.artist, album })));
return untagged ? [...byArtist, { artist: untagged.artist, album: untagged }] : byArtist;
}
return [
...filteredTracks.map((tr) => ({ key: tr.display_title || tr.name, kind: 'track', track: tr })),
...filteredArtists.map((a) => ({ key: a.artist, kind: 'artist', artist: a })),
].sort((a, b) => a.key.localeCompare(b.key));
}, [mode, filteredArtists, filteredTracks, untagged]);
const pager = usePager(units.length, pageSizeFrom(userPrefs),
`${groupId}|${mode}|${needle}|${pageResetKey || ''}`);
const pageUnits = useMemo(() => {
return units.slice(pager.start, pager.end);
}, [units, pager.start, pager.end]);
// True exactly when the library has nothing, and — now that every track
// reaches a card — exactly when the grid has nothing to draw either. The two
// used to disagree, which is the whole of the defect above.
const empty = albums.length === 0 && tracks.length === 0;
return html`
${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`
`}
${pendingAdd && html`
<${NameModal} title=${t('playlists.create')}
onSubmit=${async (name) => {
const id = await P.createPlaylist(userId, name);
await addToPlaylist(id, pendingAdd);
}}
onClose=${() => setPendingAdd(null)} />
`}
`;
}
// foldKey rides along for the Search page's merge unit keys
// (docs/MESHBAY_DESIGN.md §9.11). An album's *display* strings are the
// first-seen spelling, and which group is seen first is the order its index
// happened to arrive in — so keying a unit on them would let the chosen source
// change between page loads. The folded key is the one grouping actually used,
// and is stable.
export { MusicApp, groupMusicEntries, bumpMusicMetaGeneration, foldKey };