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, // 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, 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`
${coverHash ? html`<${MediaThumb} thumbHash=${coverHash} alt=${album.album} cls="music-cover" transportRef=${tRef} gekRef=${gRef} />` : html`<${DiscPlaceholder} cls="music-cover" />`} <${MenuDots} onOpen=${openMenu} cls="music-card-dots" title=${t('music.menu_more')} />
${album.album}
${album.artist}
<${SourceTag} entries=${album.tracks} cls="music-card-group" />
`; } // -- detail modal: tracklist + play/play-all ------------------------------- function MusicDetailModal({ album, transportRef, gekRef, musicbrainzEnabled, onClose, onPlayQueue, onMenu }) { 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 && !album.isUnknown; 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}
onMenu(e, album.tracks, 0)}> ${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 || ''}
` : ''}
<${MenuDots} onOpen=${(e) => onMenu(e, album.tracks, 0)} title=${t('music.menu_more')} />
${/* 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`
onMenu(e, [tr], 0)}> <${MenuDots} onOpen=${(e) => onMenu(e, [tr], 0)} title=${t('music.menu_more')} />
`)}
`; } // -- 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} /> `; // 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`
<${MenuDots} onOpen=${openMenu} title=${t('music.menu_more')} />
`; } function FlatAlbumFolder({ album, onPlayQueue, onMenu }) { const [open, setOpen] = useState(false); return html`
setOpen((v) => !v)} onContextMenu=${(e) => onMenu(e, album.tracks, 0)}>
<${Icon} name="folder" />
${album.album}
${t('music.n_tracks', { n: album.tracks.length })}
<${MenuDots} onOpen=${(e) => onMenu(e, album.tracks, 0)} title=${t('music.menu_more')} /> <${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} onMenu=${onMenu} onPlay=${() => onPlayQueue(album.tracks, i)} /> `)}
`}
`; } 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`
setOpen((v) => !v)} onContextMenu=${(e) => onMenu(e, allTracks, 0)}>
<${Icon} name="folder" />
${artist.artist}
${singleAlbum && !singleAlbum.isUnknown ? singleAlbum.album : t('music.n_tracks', { n: trackCount })}
<${MenuDots} onOpen=${(e) => onMenu(e, allTracks, 0)} title=${t('music.menu_more')} /> <${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 (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`
${items.map((it) => it.kind === 'track' ? html`<${FlatTrackRow} key=${it.track.id} track=${it.track} onMenu=${onMenu} onPlay=${() => onPlayQueue([it.track], 0)} />` : html`<${FlatArtistFolder} key=${it.artist.artist} artist=${it.artist} onPlayQueue=${onPlayQueue} onMenu=${onMenu} />`)}
`; } // -- 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`

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

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

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

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

${t('music.no_root_configured')}

`} ${status === 'connected' && configured && html`
${userId && html`<${PlaylistMenuButton} userId=${userId} lists=${playlists} reload=${reloadPlaylists} onPlayQueue=${onPlayQueue} onSync=${syncNow} />`} <${Pager} pager=${pager} /> ${!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} units=${pageUnits} transportRef=${transportRef} gekRef=${gekRef} musicbrainzEnabled=${musicbrainzEnabled} onPlayQueue=${onPlayQueue} onMenu=${onMenu} />` : !empty && html`<${FlatList} items=${pageUnits} onPlayQueue=${onPlayQueue} onMenu=${onMenu} />`} `} ${menu && html`<${Menu} ...${menu} onClose=${closeMenu} />`} ${note && html`
${note}
`} ${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 };