mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
9925771a86
* fix(cover): per-server cache stats and cover pipeline perf probe Stop count_cached_cover_ids from borrowing sibling bucket counts so Settings progress no longer attributes one server's disk cache to another. Add cover pipeline queue stats (ui ensure queue, ui vs lib HTTP/WebP semaphores) to Performance Probe overlay, with clearer ui/lib labels. * fix(browse): stabilize in-page infinite scroll and cap cover memory caches Extract useInpageScrollSentinel for album grids and song lists so sentinel reconnects do not spam loadMore during scroll. Harden useAlbumBrowseData with sync loading refs, tighter root margin, and hasMore termination when dedupe adds nothing. Pause middle-priority cover work during SQL pagination and bound diskSrc/resolve/ensure tail maps on long cold-cache sessions. * refactor(browse): unify in-page infinite scroll hooks and sentinel UI Extract shared transport (viewport ref, async pagination guards, client slice) and InpageScrollSentinel so Albums, New Releases, Artists, and song lists use one pagination pattern instead of duplicated IntersectionObserver wiring. * fix(browse): prioritize album SQL pagination over cover ensures Pause the entire webview ensure pump during grid page fetches, resume after SQL settles, add cover-queue backpressure before load-more, and re-probe the sentinel when pagination finishes so cold-cache scroll does not stall. * fix(browse): unblock covers, SQL spawn_blocking, and pagination retry Pair grid-pagination hold begin/end on stale fetches, resume the ensure pump after SQL, retry load-more when the cover backlog drains while the sentinel stays visible, and run album browse SQL on spawn_blocking so Tokio stays responsive during library_advanced_search. * feat(browse): All Albums client-slice scroll on local index (Artists-style) Load the filtered catalog once from SQLite when the library index is ready, then grow the visible grid with useClientSliceInfiniteScroll instead of offset SQL pagination per scroll. Network-only servers keep page mode. * fix(browse): lazy local catalog chunks instead of full 50k SQL fetch All Albums slice mode now loads 200 albums first, shows the grid immediately, then appends catalog chunks in the background as the user scrolls. Avoids the blocking library_advanced_search that hung the app on large libraries. * fix(browse): keep album covers loading during active grid scroll Pass high ensure priority and the in-page scroll root to AlbumCard on All Albums, stop pausing cover traffic for background catalog chunks, and never trim high-priority ensure jobs from the queue during scroll bursts. * fix(cover): viewport priority tiers and unstick ensure invoke pump All Albums uses IO-driven high/middle instead of blanket high; release only on unmount so scroll-ahead jobs are not dropped on reprioritize. Ensure queue shares one Rust flight per cover id, attaches duplicate waiters without consuming invoke slots, and times out wedged calls. Warm the first viewport slice on large grids; acquire CPU permits before spawn_blocking in cover_cache to avoid blocking-thread deadlocks. * fix(cover): wire in-page scroll root on New Releases and Lossless grids AlbumCard IO uses the same viewport id as VirtualCardGrid so cover ensure priority tracks visible in-page rows like All Albums. * fix(browse): lazy local artist catalog in 200-row chunks Replace runLocalBrowseAllArtists bulk fetch with paginated local-index chunks so large libraries do not hang on open; preserve text search, starred, letter filter, and client-slice scroll behavior. * feat(perf): add RSS and thread CPU groups to Performance Probe Extend performance_cpu_snapshot with process RSS (psysonic + WebKit children) and in-process thread CPU breakdown. Classify tokio-rt-worker and tokio-* workers separately from glib, audio/pipewire, reqwest, and other misc threads (Linux /proc only). * feat(perf): redesign Performance Probe with tabs, pins, and overlay layout Split the probe into Monitor (live metric cards, per-metric overlay pins, corner and opacity controls) and Toggles (diagnostic tree). Share live polling via perfLiveStore; label analysis/cover pipeline blocks in the HUD. * feat(perf): overlay sparklines, macOS CPU/memory, and sync fixes Add 1-minute pinned-metric sparklines with right-aligned growth and a shared poll clock. Enable macOS performance snapshots via sysinfo. Fix overlay infinite loop from unstable history snapshots, bar/sparkline tick jitter, and probe bar rescale flicker. * docs: CHANGELOG and credits for PR #890 * perf(probe): scoped CPU poll, adjustable interval, lazy thread groups Read only psysonic + WebKit children instead of the full process table; macOS uses sysctl host CPU and refreshes cached child PIDs. Add 0.5–10s poll slider (default 2s). Collect /proc thread groups only when the Monitor section is open or a thread metric is pinned. * feat(perf): three-way overlay mode switch (off / FPS / pinned) Add Monitor control for overlay visibility: hidden, FPS-only, or pinned metrics from Monitor. Live CPU poll runs only in pinned mode with live pins.
401 lines
15 KiB
TypeScript
401 lines
15 KiB
TypeScript
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
|
|
import { getAlbum } from '../api/subsonicLibrary';
|
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
|
import { songToTrack } from '../utils/playback/songToTrack';
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import AlbumCard from '../components/AlbumCard';
|
|
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
|
import { ndListLosslessAlbumsPage } from '../api/navidromeBrowse';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import { useOfflineStore } from '../store/offlineStore';
|
|
import { useDownloadModalStore } from '../store/downloadModalStore';
|
|
import { usePlayerStore } from '../store/playerStore';
|
|
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
|
import { useRangeSelection } from '../hooks/useRangeSelection';
|
|
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
|
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
|
import { showToast } from '../utils/ui/toast';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { join } from '@tauri-apps/api/path';
|
|
import { CheckSquare2, Download, HardDriveDownload, ListPlus } from 'lucide-react';
|
|
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
|
import OverlayScrollArea from '../components/OverlayScrollArea';
|
|
import { LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
|
import { useInpageScrollSentinel } from '../hooks/useInpageScrollSentinel';
|
|
import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
|
|
import InpageScrollSentinel from '../components/InpageScrollSentinel';
|
|
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
|
import SortDropdown from '../components/SortDropdown';
|
|
import {
|
|
albumBrowseSortForServer,
|
|
useAlbumBrowseSessionStore,
|
|
} from '../store/albumBrowseSessionStore';
|
|
import {
|
|
runLocalAlbumBrowsePage,
|
|
sortSubsonicAlbums,
|
|
type AlbumBrowseSort,
|
|
} from '../utils/library/browseTextSearch';
|
|
|
|
/** Local index page size — SQLite is cheap; larger pages than the network walk. */
|
|
const LOCAL_PAGE_SIZE = 30;
|
|
|
|
/** Per-loadMore budget for the Navidrome bit_depth song-stream fallback. */
|
|
const NETWORK_TARGET_ALBUMS = 12;
|
|
const NETWORK_SONGS_PER_FETCH = 100;
|
|
const NETWORK_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 indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
|
const sort = useAlbumBrowseSessionStore(s => albumBrowseSortForServer(s.sortByServer, serverId));
|
|
const setBrowseSort = useAlbumBrowseSessionStore(s => s.setSort);
|
|
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);
|
|
/** `true` = local SQLite; `false` = Navidrome song-stream walk; `null` until first fetch picks. */
|
|
const [useLocalIndex, setUseLocalIndex] = useState<boolean | null>(null);
|
|
|
|
const displayAlbums = useMemo(() => {
|
|
if (useLocalIndex === false) return sortSubsonicAlbums(albums, sort);
|
|
return albums;
|
|
}, [albums, sort, useLocalIndex]);
|
|
|
|
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(displayAlbums);
|
|
const selectedAlbums = displayAlbums.filter(a => selectedIds.has(a.id));
|
|
|
|
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
|
|
const clearSelection = () => { setSelectionMode(false); resetSelection(); };
|
|
|
|
/** Network pagination cursor — unused on the local path. */
|
|
const songCursor = useRef(0);
|
|
const seenIds = useRef<Set<string>>(new Set());
|
|
const localOffset = useRef(0);
|
|
const inFlight = useRef(false);
|
|
const {
|
|
scrollBodyEl,
|
|
bindScrollBody: bindLosslessScrollBody,
|
|
getScrollRoot,
|
|
} = useInpageScrollViewport();
|
|
|
|
const sortOptions: { value: AlbumBrowseSort; label: string }[] = [
|
|
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
|
|
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
|
|
];
|
|
|
|
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
|
|
unsupported,
|
|
selectionMode,
|
|
activeServerId,
|
|
sort,
|
|
]);
|
|
|
|
const loadMoreNetwork = useCallback(async (onProgress?: (albums: SubsonicAlbum[]) => void) => {
|
|
const page = await ndListLosslessAlbumsPage({
|
|
startSongOffset: songCursor.current,
|
|
seenAlbumIds: seenIds.current,
|
|
targetNewAlbums: NETWORK_TARGET_ALBUMS,
|
|
songsPerPage: NETWORK_SONGS_PER_FETCH,
|
|
maxPagesPerCall: NETWORK_MAX_FETCHES_PER_LOAD,
|
|
onProgress: onProgress
|
|
? (entries) => { onProgress(entries.map(e => e.album)); }
|
|
: undefined,
|
|
});
|
|
songCursor.current = page.nextSongOffset;
|
|
return page;
|
|
}, []);
|
|
|
|
const loadMoreLocal = useCallback(async () => {
|
|
const data = await runLocalAlbumBrowsePage(
|
|
serverId,
|
|
sort,
|
|
localOffset.current,
|
|
LOCAL_PAGE_SIZE,
|
|
undefined,
|
|
true,
|
|
);
|
|
if (data == null) return null;
|
|
localOffset.current += data.length;
|
|
return { albums: data, hasMore: data.length === LOCAL_PAGE_SIZE };
|
|
}, [serverId, sort]);
|
|
|
|
const loadMore = useCallback(async () => {
|
|
if (inFlight.current || useLocalIndex === null) return;
|
|
inFlight.current = true;
|
|
setLoading(true);
|
|
try {
|
|
if (useLocalIndex) {
|
|
const page = await loadMoreLocal();
|
|
if (!page) {
|
|
setHasMore(false);
|
|
return;
|
|
}
|
|
setAlbums(prev => [...prev, ...page.albums]);
|
|
setHasMore(page.hasMore);
|
|
} else {
|
|
const page = await loadMoreNetwork(albums => {
|
|
setAlbums(prev => [...prev, ...albums]);
|
|
});
|
|
setHasMore(!page.done);
|
|
}
|
|
} catch {
|
|
if (!useLocalIndex) {
|
|
setUnsupported(true);
|
|
}
|
|
setHasMore(false);
|
|
} finally {
|
|
inFlight.current = false;
|
|
setLoading(false);
|
|
}
|
|
}, [loadMoreLocal, loadMoreNetwork, useLocalIndex]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
songCursor.current = 0;
|
|
seenIds.current = new Set();
|
|
localOffset.current = 0;
|
|
inFlight.current = false;
|
|
setAlbums([]);
|
|
setHasMore(true);
|
|
setUnsupported(false);
|
|
setUseLocalIndex(null);
|
|
setLoading(true);
|
|
|
|
(async () => {
|
|
inFlight.current = true;
|
|
try {
|
|
if (indexEnabled && serverId) {
|
|
const data = await runLocalAlbumBrowsePage(
|
|
serverId,
|
|
sort,
|
|
0,
|
|
LOCAL_PAGE_SIZE,
|
|
undefined,
|
|
true,
|
|
);
|
|
if (cancelled) return;
|
|
if (data != null) {
|
|
setUseLocalIndex(true);
|
|
localOffset.current = data.length;
|
|
setAlbums(data);
|
|
setHasMore(data.length === LOCAL_PAGE_SIZE);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (cancelled) return;
|
|
setUseLocalIndex(false);
|
|
const page = await loadMoreNetwork(albums => {
|
|
if (!cancelled) setAlbums(prev => [...prev, ...albums]);
|
|
});
|
|
if (cancelled) return;
|
|
songCursor.current = page.nextSongOffset;
|
|
setHasMore(!page.done);
|
|
} catch {
|
|
if (cancelled) return;
|
|
setUseLocalIndex(false);
|
|
setUnsupported(true);
|
|
setHasMore(false);
|
|
} finally {
|
|
inFlight.current = false;
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
})();
|
|
|
|
return () => { cancelled = true; };
|
|
}, [activeServerId, indexEnabled, loadMoreNetwork, serverId, sort]);
|
|
|
|
const bindLoadMoreSentinel = useInpageScrollSentinel({
|
|
active: hasMore && useLocalIndex !== null,
|
|
getScrollRoot,
|
|
scrollRootEl: scrollBodyEl,
|
|
onIntersect: () => { void loadMore(); },
|
|
rootMargin: '200px',
|
|
});
|
|
|
|
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 mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}>
|
|
{!perfFlags.disableMainstageStickyHeader && (
|
|
<div className="mainstage-inpage-toolbar">
|
|
<div className="page-sticky-header mainstage-inpage-toolbar-row">
|
|
<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) && useLocalIndex === false && (
|
|
<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) && (
|
|
<SortDropdown
|
|
value={sort}
|
|
options={sortOptions}
|
|
onChange={value => setBrowseSort(serverId, value)}
|
|
/>
|
|
)}
|
|
{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>
|
|
</div>
|
|
)}
|
|
|
|
<OverlayScrollArea
|
|
className="mainstage-inpage-scroll"
|
|
viewportClassName="mainstage-inpage-scroll__viewport"
|
|
viewportId={LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
|
|
viewportRef={bindLosslessScrollBody}
|
|
railInset="panel"
|
|
measureDeps={[
|
|
unsupported,
|
|
loading,
|
|
albums.length,
|
|
hasMore,
|
|
selectionMode,
|
|
useLocalIndex,
|
|
perfFlags.disableMainstageVirtualLists,
|
|
perfFlags.disableMainstageStickyHeader,
|
|
]}
|
|
>
|
|
{unsupported ? (
|
|
<div style={{ padding: '3rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
|
{t('losslessAlbums.unsupported')}
|
|
</div>
|
|
) : loading && displayAlbums.length === 0 ? (
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
|
<div className="spinner" />
|
|
</div>
|
|
) : displayAlbums.length === 0 ? (
|
|
<div style={{ padding: '3rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
|
{t('losslessAlbums.empty')}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<VirtualCardGrid
|
|
items={displayAlbums}
|
|
itemKey={(a, _i) => a.id}
|
|
rowVariant="album"
|
|
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
|
layoutSignal={displayAlbums.length}
|
|
scrollRootId={LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
|
|
warmGridCovers={albumGridWarmCovers()}
|
|
renderItem={a => (
|
|
<AlbumCard
|
|
album={a}
|
|
observeScrollRootId={LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
|
|
linkQuery={LOSSLESS_MODE_QUERY}
|
|
selectionMode={selectionMode}
|
|
selected={selectedIds.has(a.id)}
|
|
onToggleSelect={toggleSelect}
|
|
selectedAlbums={selectedAlbums}
|
|
/>
|
|
)}
|
|
/>
|
|
{hasMore && useLocalIndex !== null && (
|
|
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loading} />
|
|
)}
|
|
</>
|
|
)}
|
|
</OverlayScrollArea>
|
|
</div>
|
|
);
|
|
}
|