mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(home): Lossless Albums rail + dedicated page + sidebar nav (#506)
* wip(home): Lossless rail + dedicated /lossless-albums page Rail under Home > mostPlayed and a dedicated infinite-scroll page that list albums whose tracks are tagged in lossless containers. Walks Navidrome's native /api/song?_sort=bit_depth&_order=DESC, dedupes by albumId on the way down, stops when the song stream crosses into lossy (bitDepth==0) or the server runs out of rows. _filters has no operators on quality columns, so a sort + walk is the only path; equality on sample_rate / bit_depth probes returned empty (verified). The page paginates through the song-cursor with an in-flight ref so overlapping IntersectionObserver fires don't double-add albums, plus a cancelled flag so React StrictMode's double-mount doesn't apply two parallel result sets in dev. Suffix allowlist excludes ambiguous wrappers — m4a/m4b can be ALAC *or* AAC and Navidrome's response carries an empty codec field, so we can't tell them apart; same story for wma. Allowlist is flac, wav, aiff/aif, dsf/dff, ape, wv, shn, tta — containers that are only lossless. ALAC-in-m4a setups will miss out, acceptable trade-off without a reliable codec field. Status: WIP. Settings home-customizer label and en.ts strings landed, no other locales yet, no quality badge on AlbumCards across the rest of the app, no CHANGELOG. Branch was 'feat/home-hires-rail' during the earlier hi-res-only iteration before the lossless broadening. * wip(home): Lossless page header parity + streaming + sidebar nav Page header now mirrors All Albums: selection mode with three action buttons (Enqueue Selected, Add Offline, Download ZIPs) wired to the same handlers Albums.tsx uses, selection-counter title swap, and the perfFlags.disableMainstageStickyHeader respect path. Filters were intentionally skipped — the rail is sorted by bit_depth, mixing client- side filters with server-driven pagination would produce gaps. Loading feels noticeably faster: ndListLosslessAlbumsPage takes an optional onProgress callback that fires once per internal fetch with the entries discovered in that fetch, so the page can stream new albums into state instead of waiting on the whole loadMore. Page-side budget dropped from 5×200 to 2×100 songs per loadMore (~1 MB worst case vs 5 MB before), since the rail's catch-em-all pass is wrong for infinite-scroll UX. Subtitle under the page title primes the user that this is slower than other album pages because Psysonic walks the song catalog by quality (Navidrome ignores _fields, so per-song responses ship with lyrics + tags + participants whether we want them or not). Sidebar nav entry registered under 'losslessAlbums' with a Gem icon, defaults to visible:false (matches composers / folderBrowser / deviceSync — niche browsing modes). Existing users get the entry appended at the end of their persisted sidebar list automatically via the onRehydrateStorage merge that sidebarStore already runs for new DEFAULT_SIDEBAR_ITEMS. i18n: full coverage across all 8 locales for sidebar.losslessAlbums, home.losslessAlbums, losslessAlbums.empty, losslessAlbums.unsupported and the new losslessAlbums.slowFetchHint subtitle. ru/zh are machine-translation quality, flagged for a polish pass. * feat(home): default Lossless sidebar entry to visible Flips the DEFAULT_SIDEBAR_ITEMS entry for `losslessAlbums` from false to true. Existing installs keep whatever the user has in persisted storage; fresh installs see the entry in the sidebar from the start. Earlier wip commit defaulted it off (matched composers / folderBrowser / deviceSync as a niche browse mode), but the rail + page do show something useful for any library with at least one FLAC/WAV/etc. album, so off-by-default just hid the feature. * docs: changelog entry for PR #506 Logs the Lossless Albums rail + page + sidebar entry in v1.46.0 "## Added". * docs(settings): contributor entry for PR #506 Adds the Lossless Albums rail/page bullet to Psychotoxical's contributions list.
This commit is contained in:
committed by
GitHub
parent
a41e3a624a
commit
bd56177e2c
@@ -0,0 +1,280 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { ndListLosslessAlbumsPage } from '../api/navidromeBrowse';
|
||||
import { getAlbum, type SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||
import { useRangeSelection } from '../hooks/useRangeSelection';
|
||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { CheckSquare2, Download, HardDriveDownload, ListPlus } from 'lucide-react';
|
||||
|
||||
/** Per-loadMore budget — tuned for snappy initial paint over completeness.
|
||||
* 100 songs ≈ 500 KB response (Navidrome's /api/song carries lyrics/tags/
|
||||
* participants and ignores `_fields`); 2 internal pages = ~1 MB worst case
|
||||
* per loadMore, much faster than the rail's 5×200 = 1000-song budget. The
|
||||
* page makes up for the smaller batch by triggering a fresh loadMore on
|
||||
* scroll, so the user sees albums sooner instead of waiting on a fat call. */
|
||||
const PAGE_TARGET_ALBUMS = 12;
|
||||
const PAGE_SONGS_PER_FETCH = 100;
|
||||
const PAGE_MAX_FETCHES_PER_LOAD = 2;
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
|
||||
}
|
||||
|
||||
export default function LosslessAlbums() {
|
||||
const { t } = useTranslation();
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const auth = useAuthStore();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [unsupported, setUnsupported] = useState(false);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
|
||||
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums);
|
||||
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
||||
|
||||
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
|
||||
const clearSelection = () => { setSelectionMode(false); resetSelection(); };
|
||||
|
||||
/** Pagination cursor + dedupe set, kept across loadMore calls so each page
|
||||
* resumes the song-stream walk where the previous one left off. Reset to
|
||||
* a fresh pair whenever the active server changes. */
|
||||
const songCursor = useRef(0);
|
||||
const seenIds = useRef<Set<string>>(new Set());
|
||||
/** Re-entrancy guard. The IntersectionObserver can fire repeatedly while a
|
||||
* previous loadMore is still in flight (fast scroll, sentinel re-entering
|
||||
* the rootMargin band) — without this guard, two concurrent calls would
|
||||
* read the same songCursor, fetch the same song page, and push duplicate
|
||||
* album entries because each captures its own snapshot of the seen-Set
|
||||
* reference. */
|
||||
const inFlight = useRef(false);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const page = await ndListLosslessAlbumsPage({
|
||||
startSongOffset: songCursor.current,
|
||||
seenAlbumIds: seenIds.current,
|
||||
targetNewAlbums: PAGE_TARGET_ALBUMS,
|
||||
songsPerPage: PAGE_SONGS_PER_FETCH,
|
||||
maxPagesPerCall: PAGE_MAX_FETCHES_PER_LOAD,
|
||||
onProgress: (newEntries) => {
|
||||
setAlbums(prev => [...prev, ...newEntries.map(e => e.album)]);
|
||||
},
|
||||
});
|
||||
songCursor.current = page.nextSongOffset;
|
||||
setHasMore(!page.done);
|
||||
} catch {
|
||||
setUnsupported(true);
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
songCursor.current = 0;
|
||||
seenIds.current = new Set();
|
||||
inFlight.current = false;
|
||||
setAlbums([]);
|
||||
setHasMore(true);
|
||||
setUnsupported(false);
|
||||
setLoading(true);
|
||||
|
||||
(async () => {
|
||||
inFlight.current = true;
|
||||
try {
|
||||
const page = await ndListLosslessAlbumsPage({
|
||||
startSongOffset: 0,
|
||||
seenAlbumIds: seenIds.current,
|
||||
targetNewAlbums: PAGE_TARGET_ALBUMS,
|
||||
songsPerPage: PAGE_SONGS_PER_FETCH,
|
||||
maxPagesPerCall: PAGE_MAX_FETCHES_PER_LOAD,
|
||||
onProgress: (newEntries) => {
|
||||
if (cancelled) return;
|
||||
setAlbums(prev => [...prev, ...newEntries.map(e => e.album)]);
|
||||
},
|
||||
});
|
||||
if (cancelled) return;
|
||||
songCursor.current = page.nextSongOffset;
|
||||
setHasMore(!page.done);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setUnsupported(true);
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [activeServerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore) return;
|
||||
const node = observerTarget.current;
|
||||
if (!node) return;
|
||||
const obs = new IntersectionObserver(
|
||||
entries => { if (entries[0].isIntersecting) loadMore(); },
|
||||
{ rootMargin: '200px' },
|
||||
);
|
||||
obs.observe(node);
|
||||
return () => obs.disconnect();
|
||||
}, [hasMore, loadMore, loading, albums.length]);
|
||||
|
||||
const handleEnqueueSelected = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
try {
|
||||
const results = await Promise.all(selectedAlbums.map(a => getAlbum(a.id).catch(() => null)));
|
||||
const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []);
|
||||
if (tracks.length > 0) {
|
||||
enqueue(tracks);
|
||||
showToast(t('albums.enqueueQueued', { count: selectedAlbums.length }), 2500, 'info');
|
||||
}
|
||||
} finally {
|
||||
clearSelection();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddOffline = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
let queued = 0;
|
||||
for (const album of selectedAlbums) {
|
||||
try {
|
||||
const detail = await getAlbum(album.id);
|
||||
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
|
||||
queued++;
|
||||
} catch {
|
||||
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
||||
}
|
||||
}
|
||||
if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info');
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const handleDownloadZips = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
const folder = auth.downloadFolder || await requestDownloadFolder();
|
||||
if (!folder) return;
|
||||
const { start, complete, fail } = useZipDownloadStore.getState();
|
||||
clearSelection();
|
||||
for (const album of selectedAlbums) {
|
||||
const downloadId = crypto.randomUUID();
|
||||
const filename = `${sanitizeFilename(album.name)}.zip`;
|
||||
const destPath = await join(folder, filename);
|
||||
const url = buildDownloadUrl(album.id);
|
||||
start(downloadId, filename);
|
||||
try {
|
||||
await invoke('download_zip', { id: downloadId, url, destPath });
|
||||
complete(downloadId);
|
||||
} catch (e) {
|
||||
fail(downloadId);
|
||||
console.error('ZIP download failed for', album.name, e);
|
||||
showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
{!perfFlags.disableMainstageStickyHeader && (
|
||||
<div className="page-sticky-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.15rem', minWidth: 0 }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
{selectionMode && selectedIds.size > 0
|
||||
? t('albums.selectionCount', { count: selectedIds.size })
|
||||
: t('home.losslessAlbums')}
|
||||
</h1>
|
||||
{!(selectionMode && selectedIds.size > 0) && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: 1.3 }}>
|
||||
{t('losslessAlbums.slowFetchHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{selectionMode && selectedIds.size > 0 && (
|
||||
<>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleEnqueueSelected}>
|
||||
<ListPlus size={15} />
|
||||
{t('albums.enqueueSelected', { count: selectedIds.size })}
|
||||
</button>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
|
||||
<HardDriveDownload size={15} />
|
||||
{t('albums.addOffline')}
|
||||
</button>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
|
||||
<Download size={15} />
|
||||
{t('albums.downloadZips')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unsupported ? (
|
||||
<div style={{ padding: '3rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
{t('losslessAlbums.unsupported')}
|
||||
</div>
|
||||
) : loading && albums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : albums.length === 0 ? (
|
||||
<div style={{ padding: '3rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
{t('losslessAlbums.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => (
|
||||
<AlbumCard
|
||||
key={a.id}
|
||||
album={a}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectedAlbums={selectedAlbums}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user