mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(library): local lossless index, filters, and conserve dedicated page (#871)
* feat(library): local lossless index, filters, and conserve dedicated page Add SQLite-backed lossless album browse and advanced-search filtering, wire All Albums and artist/album lossless drill-down mode, and hide the standalone /lossless-albums nav entry from sidebar visibility settings (conserved route, default off). * docs(release): note lossless local index in CHANGELOG and credits (PR #871)
This commit is contained in:
@@ -14,6 +14,8 @@ import StarFilterButton from '../components/StarFilterButton';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { runLocalAdvancedSearch, loadMoreLocalSongs, runNetworkAdvancedTextSearch } from '../utils/library/advancedSearchLocal';
|
||||
import { isLosslessSuffix } from '../utils/library/losslessFormats';
|
||||
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
||||
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from '../utils/library/trackEnrichment';
|
||||
import { raceSearchSources } from '../utils/library/searchRace';
|
||||
import { logLibrarySearch } from '../utils/library/libraryDevLog';
|
||||
@@ -32,6 +34,7 @@ interface SearchOpts {
|
||||
bpmFrom: string;
|
||||
bpmTo: string;
|
||||
moodGroup: string;
|
||||
losslessOnly: boolean;
|
||||
resultType: ResultType;
|
||||
}
|
||||
|
||||
@@ -59,6 +62,7 @@ export default function AdvancedSearch() {
|
||||
const [bpmFrom, setBpmFrom] = useState('');
|
||||
const [bpmTo, setBpmTo] = useState('');
|
||||
const [moodGroup, setMoodGroup] = useState('');
|
||||
const [losslessOnly, setLosslessOnly] = useState(false);
|
||||
const [resultType, setResultType] = useState<ResultType>('all');
|
||||
const [starredOnly, setStarredOnly] = useState(false);
|
||||
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||||
@@ -104,6 +108,7 @@ export default function AdvancedSearch() {
|
||||
to: number | null,
|
||||
bpmLo: number | null,
|
||||
bpmHi: number | null,
|
||||
lossless = false,
|
||||
): SubsonicSong[] => {
|
||||
let r = list;
|
||||
if (g) r = r.filter(s => s.genre?.toLowerCase() === g.toLowerCase());
|
||||
@@ -111,6 +116,7 @@ export default function AdvancedSearch() {
|
||||
if (to !== null) r = r.filter(s => !s.year || s.year <= to);
|
||||
if (bpmLo !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm >= bpmLo);
|
||||
if (bpmHi !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm <= bpmHi);
|
||||
if (lossless) r = r.filter(s => isLosslessSuffix(s.suffix));
|
||||
return r;
|
||||
};
|
||||
|
||||
@@ -129,10 +135,12 @@ export default function AdvancedSearch() {
|
||||
const searchT0 = performance.now();
|
||||
const moodFilterActive = MOOD_UI_ENABLED && !!opts.moodGroup;
|
||||
const bpmFilterActive = !!(opts.bpmFrom || opts.bpmTo);
|
||||
const losslessFilterActive = opts.losslessOnly;
|
||||
const trackOnlyFilterActive = moodFilterActive || bpmFilterActive;
|
||||
|
||||
// Track-only filters (BPM dual-storage, mood) need the local index for full coverage.
|
||||
if (q && serverId && indexEnabled && !trackOnlyFilterActive) {
|
||||
// Lossless skips the race — network search3 cannot filter albums by format reliably.
|
||||
if (q && serverId && indexEnabled && !trackOnlyFilterActive && !losslessFilterActive) {
|
||||
try {
|
||||
const winner = await raceSearchSources(
|
||||
[
|
||||
@@ -205,13 +213,13 @@ export default function AdvancedSearch() {
|
||||
setLocalMode(false);
|
||||
}
|
||||
|
||||
if (trackOnlyFilterActive && !indexEnabled) {
|
||||
if ((trackOnlyFilterActive || losslessFilterActive) && !indexEnabled) {
|
||||
setResults({ artists: [], albums: [], songs: [] });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { genre: g, yearFrom: yf, yearTo: yt, bpmFrom: bf, bpmTo: bt, resultType: rt } = opts;
|
||||
const { genre: g, yearFrom: yf, yearTo: yt, bpmFrom: bf, bpmTo: bt, losslessOnly: lossless, resultType: rt } = opts;
|
||||
const from = yf ? parseInt(yf) : null;
|
||||
const to = yt ? parseInt(yt) : null;
|
||||
const bpmLo = bf ? parseInt(bf) : null;
|
||||
@@ -226,7 +234,7 @@ export default function AdvancedSearch() {
|
||||
const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: SONGS_INITIAL });
|
||||
artists = r.artists;
|
||||
albums = r.albums;
|
||||
songs = applySongFilters(r.songs, g, from, to, bpmLo, bpmHi);
|
||||
songs = applySongFilters(r.songs, g, from, to, bpmLo, bpmHi, lossless);
|
||||
|
||||
if (g) {
|
||||
albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase());
|
||||
@@ -237,6 +245,12 @@ export default function AdvancedSearch() {
|
||||
if (to !== null) {
|
||||
albums = albums.filter(a => !a.year || a.year <= to);
|
||||
}
|
||||
if (lossless) {
|
||||
const albumIds = new Set(songs.map(s => s.albumId).filter(Boolean));
|
||||
albums = albums.filter(a => albumIds.has(a.id));
|
||||
const artistIds = new Set(songs.map(s => s.artistId).filter(Boolean));
|
||||
artists = artists.filter(a => artistIds.has(a.id));
|
||||
}
|
||||
|
||||
// Only the free-text branch supports server-side pagination via search3 offset.
|
||||
// If the server returned a full page, more probably exist.
|
||||
@@ -249,7 +263,7 @@ export default function AdvancedSearch() {
|
||||
]);
|
||||
albums = albumRes as SubsonicAlbum[];
|
||||
songs = songRes as SubsonicSong[];
|
||||
songs = applySongFilters(songs, g, from, to, bpmLo, bpmHi);
|
||||
songs = applySongFilters(songs, g, from, to, bpmLo, bpmHi, lossless);
|
||||
if (from !== null) albums = albums.filter(a => !a.year || a.year >= from);
|
||||
if (to !== null) albums = albums.filter(a => !a.year || a.year <= to);
|
||||
if (songs.length > 0) setGenreNote(true);
|
||||
@@ -300,6 +314,7 @@ export default function AdvancedSearch() {
|
||||
bpmFrom: '',
|
||||
bpmTo: '',
|
||||
moodGroup: '',
|
||||
losslessOnly: false,
|
||||
resultType: 'all',
|
||||
});
|
||||
}
|
||||
@@ -335,7 +350,15 @@ export default function AdvancedSearch() {
|
||||
const bpmLo = activeSearch.bpmFrom ? parseInt(activeSearch.bpmFrom) : null;
|
||||
const bpmHi = activeSearch.bpmTo ? parseInt(activeSearch.bpmTo) : null;
|
||||
const page = await searchSongsPaged(q, SONGS_PAGE_SIZE, songsServerOffset);
|
||||
const filtered = applySongFilters(page, g, from, to, bpmLo, bpmHi);
|
||||
const filtered = applySongFilters(
|
||||
page,
|
||||
g,
|
||||
from,
|
||||
to,
|
||||
bpmLo,
|
||||
bpmHi,
|
||||
activeSearch.losslessOnly,
|
||||
);
|
||||
setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...filtered] } : prev);
|
||||
setSongsServerOffset(o => o + page.length);
|
||||
// No more pages when the server returned a non-full page (regardless of how many survived filtering).
|
||||
@@ -368,6 +391,7 @@ export default function AdvancedSearch() {
|
||||
bpmFrom,
|
||||
bpmTo,
|
||||
moodGroup,
|
||||
losslessOnly,
|
||||
resultType: effectiveType,
|
||||
});
|
||||
};
|
||||
@@ -464,7 +488,7 @@ export default function AdvancedSearch() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 3: BPM (tag + measured enrichment via local index) */}
|
||||
{/* Row 3: BPM (tag + measured enrichment) */}
|
||||
{indexEnabled && (
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
|
||||
@@ -519,9 +543,32 @@ export default function AdvancedSearch() {
|
||||
{t('search.advancedBpmClear')}
|
||||
</button>
|
||||
)}
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('search.advancedBpmLocalNote')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lossless — suffix allowlist (FLAC, WAV, …) */}
|
||||
{indexEnabled && (
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
|
||||
{t('search.advancedLossless')}
|
||||
</span>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.45rem',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={losslessOnly}
|
||||
onChange={e => setLosslessOnly(e.target.checked)}
|
||||
/>
|
||||
{t('search.advancedLosslessOnly')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -594,6 +641,7 @@ export default function AdvancedSearch() {
|
||||
<ArtistRow
|
||||
title={`${t('search.artists')} (${filteredResults.artists.length})`}
|
||||
artists={filteredResults.artists}
|
||||
artistLinkQuery={activeSearch?.losslessOnly ? LOSSLESS_MODE_QUERY : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -601,6 +649,7 @@ export default function AdvancedSearch() {
|
||||
<AlbumRow
|
||||
title={`${t('search.albums')} (${filteredResults.albums.length})`}
|
||||
albums={filteredResults.albums}
|
||||
albumLinkQuery={activeSearch?.losslessOnly ? LOSSLESS_MODE_QUERY : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+26
-13
@@ -6,7 +6,7 @@ import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { shuffleArray } from '../utils/playback/shuffleArray';
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -31,12 +31,17 @@ import { deriveAlbumHeaderArtistRefs } from '../utils/album/deriveAlbumHeaderArt
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
import LosslessModeBanner from '../components/LosslessModeBanner';
|
||||
import { isLosslessSuffix } from '../utils/library/losslessFormats';
|
||||
import { isLosslessMode } from '../utils/library/losslessMode';
|
||||
|
||||
export default function AlbumDetail() {
|
||||
const { t } = useTranslation();
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const losslessOnly = isLosslessMode(searchParams);
|
||||
const auth = useAuthStore();
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
@@ -79,10 +84,16 @@ export default function AlbumDetail() {
|
||||
if (album && album.album.id === id) setAlbumEntityRating(album.album.userRating ?? 0);
|
||||
}, [id, album?.album.id, album?.album.userRating]);
|
||||
|
||||
const effectiveSongs = useMemo(() => {
|
||||
if (!album?.songs) return undefined;
|
||||
if (!losslessOnly) return album.songs;
|
||||
return album.songs.filter(s => isLosslessSuffix(s.suffix));
|
||||
}, [album?.songs, losslessOnly]);
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (!album) return;
|
||||
if (!album || !effectiveSongs) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const tracks = album.songs.map(s => {
|
||||
const tracks = effectiveSongs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
@@ -91,9 +102,9 @@ const handlePlayAll = () => {
|
||||
};
|
||||
|
||||
const handleEnqueueAll = () => {
|
||||
if (!album) return;
|
||||
if (!album || !effectiveSongs) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const tracks = album.songs.map(s => {
|
||||
const tracks = effectiveSongs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
@@ -102,9 +113,9 @@ const handleEnqueueAll = () => {
|
||||
};
|
||||
|
||||
const handleShuffleAll = () => {
|
||||
if (!album) return;
|
||||
if (!album || !effectiveSongs) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const tracks = album.songs.map(s => {
|
||||
const tracks = effectiveSongs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
@@ -117,9 +128,9 @@ const handleShuffleAll = () => {
|
||||
|
||||
const handlePlaySong = (song: SubsonicSong) => {
|
||||
if (orbitActive) { queueHint(); return; }
|
||||
if (!album) return;
|
||||
if (!album || !effectiveSongs) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const tracks = album.songs.map(s => {
|
||||
const tracks = effectiveSongs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
@@ -230,8 +241,8 @@ const handleShuffleAll = () => {
|
||||
// If we can't check, proceed anyway
|
||||
}
|
||||
setOfflineStorageFull(false);
|
||||
downloadAlbum(album.album.id, album.album.name, album.album.artist, album.album.coverArt, album.album.year, album.songs, serverId);
|
||||
}, [album, auth.maxCacheMb, downloadAlbum, serverId]);
|
||||
downloadAlbum(album.album.id, album.album.name, album.album.artist, album.album.coverArt, album.album.year, effectiveSongs ?? album.songs, serverId);
|
||||
}, [album, auth.maxCacheMb, downloadAlbum, serverId, effectiveSongs]);
|
||||
|
||||
const handleRemoveOffline = () => {
|
||||
if (!album) return;
|
||||
@@ -245,7 +256,7 @@ const handleShuffleAll = () => {
|
||||
]), [starredSongs, starredOverrides]);
|
||||
|
||||
const { sortKey, sortDir, handleSort, displayedSongs } = useAlbumDetailSort({
|
||||
songs: album?.songs,
|
||||
songs: effectiveSongs,
|
||||
filterText,
|
||||
starredSongs: mergedStarredSongs,
|
||||
ratings,
|
||||
@@ -271,7 +282,8 @@ const handleShuffleAll = () => {
|
||||
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
|
||||
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
|
||||
|
||||
const { album: info, songs } = album;
|
||||
const { album: info } = album;
|
||||
const songs = effectiveSongs ?? [];
|
||||
const headerArtistRefs = deriveAlbumHeaderArtistRefs(info, songs);
|
||||
const hasVariousArtists = songs.some(s => s.artist !== info.artist);
|
||||
|
||||
@@ -302,6 +314,7 @@ const handleShuffleAll = () => {
|
||||
onEntityRatingChange={handleAlbumEntityRating}
|
||||
entityRatingSupport={albumEntityRatingSupport}
|
||||
/>
|
||||
{losslessOnly && <LosslessModeBanner />}
|
||||
{offlineStorageFull && (
|
||||
<div className="offline-storage-full-banner" role="alert">
|
||||
<span>{t('albumDetail.offlineStorageFull', { mb: auth.maxCacheMb })}</span>
|
||||
|
||||
+112
-9
@@ -15,6 +15,7 @@ import { computeCardGridColumnCount } from '../utils/cardGridLayout';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import YearFilterButton from '../components/YearFilterButton';
|
||||
import StarFilterButton from '../components/StarFilterButton';
|
||||
import LosslessFilterButton from '../components/LosslessFilterButton';
|
||||
import SortDropdown from '../components/SortDropdown';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
@@ -35,8 +36,10 @@ import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import {
|
||||
runLocalAlbumBrowsePage,
|
||||
runLocalAlbumsByGenres,
|
||||
runLocalLosslessAlbums,
|
||||
type AlbumBrowseSort,
|
||||
} from '../utils/library/browseTextSearch';
|
||||
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
||||
|
||||
type SortType = AlbumBrowseSort;
|
||||
type CompFilter = 'all' | 'only' | 'hide';
|
||||
@@ -72,6 +75,7 @@ export default function Albums() {
|
||||
const [yearTo, setYearTo] = useState('');
|
||||
const [compFilter, setCompFilter] = useState<CompFilter>('all');
|
||||
const [starredOnly, setStarredOnly] = useState(false);
|
||||
const [losslessOnly, setLosslessOnly] = useState(false);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const gridMeasureRef = useRef<HTMLDivElement>(null);
|
||||
const maxGridCols = useAuthStore(s => clampLibraryGridMaxColumns(s.libraryGridMaxColumns));
|
||||
@@ -90,6 +94,7 @@ export default function Albums() {
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const clientFilterActive = starredOnly || compFilter !== 'all';
|
||||
const visibleAlbums = useMemo(() => {
|
||||
let out = albums;
|
||||
if (compFilter === 'only') out = out.filter(a => a.isCompilation);
|
||||
@@ -179,6 +184,15 @@ export default function Albums() {
|
||||
const toNum = parseInt(yearTo, 10);
|
||||
const yearActive = !isNaN(fromNum) && !isNaN(toNum) && fromNum >= 1 && toNum >= 1;
|
||||
|
||||
const pendingClientFilterMatch =
|
||||
clientFilterActive && visibleAlbums.length === 0 && hasMore && !genreFiltered;
|
||||
|
||||
const visibleEmptyMessage = useMemo(() => {
|
||||
if (starredOnly) return t('albums.noFavorites');
|
||||
if (compFilter === 'only') return t('albums.noCompilations');
|
||||
return t('albums.noMatchingFilters');
|
||||
}, [starredOnly, compFilter, t]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = gridMeasureRef.current;
|
||||
if (!el) return;
|
||||
@@ -210,6 +224,7 @@ export default function Albums() {
|
||||
yearTo,
|
||||
compFilter,
|
||||
starredOnly,
|
||||
losslessOnly,
|
||||
selectionMode,
|
||||
selectedGenres,
|
||||
]);
|
||||
@@ -219,9 +234,47 @@ export default function Albums() {
|
||||
offset: number,
|
||||
append = false,
|
||||
yearFilter?: { from: number; to: number },
|
||||
lossless = false,
|
||||
) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (lossless) {
|
||||
if (!indexEnabled || !serverId) {
|
||||
setAlbums([]);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
if (!yearFilter) {
|
||||
const page = await runLocalLosslessAlbums(serverId, PAGE_SIZE, offset);
|
||||
if (!page) {
|
||||
setAlbums([]);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
if (append) setAlbums(prev => dedupeById([...prev, ...page.albums]));
|
||||
else setAlbums(page.albums);
|
||||
setHasMore(page.hasMore);
|
||||
return;
|
||||
}
|
||||
const data = await runLocalAlbumBrowsePage(
|
||||
serverId,
|
||||
sortType,
|
||||
offset,
|
||||
PAGE_SIZE,
|
||||
yearFilter,
|
||||
true,
|
||||
);
|
||||
if (data == null) {
|
||||
setAlbums([]);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
if (append) setAlbums(prev => [...prev, ...data]);
|
||||
else setAlbums(data);
|
||||
setHasMore(data.length === PAGE_SIZE);
|
||||
return;
|
||||
}
|
||||
|
||||
let data: SubsonicAlbum[] | null = null;
|
||||
if (indexEnabled && serverId) {
|
||||
data = await runLocalAlbumBrowsePage(
|
||||
@@ -230,6 +283,7 @@ export default function Albums() {
|
||||
offset,
|
||||
PAGE_SIZE,
|
||||
yearFilter,
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (data == null) {
|
||||
@@ -245,9 +299,25 @@ export default function Albums() {
|
||||
}
|
||||
}, [musicLibraryFilterVersion, indexEnabled, serverId]);
|
||||
|
||||
const loadFiltered = useCallback(async (genres: string[], sortType: SortType) => {
|
||||
const loadFiltered = useCallback(async (
|
||||
genres: string[],
|
||||
sortType: SortType,
|
||||
lossless: boolean,
|
||||
) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (lossless) {
|
||||
if (!indexEnabled || !serverId) {
|
||||
setAlbums([]);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
const data = await runLocalAlbumsByGenres(serverId, genres, sortType, undefined, true);
|
||||
setAlbums(data ?? []);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let data: SubsonicAlbum[] | null = null;
|
||||
if (indexEnabled && serverId) {
|
||||
data = await runLocalAlbumsByGenres(serverId, genres, sortType);
|
||||
@@ -270,21 +340,36 @@ export default function Albums() {
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
if (genreFiltered) {
|
||||
loadFiltered(selectedGenres, sort);
|
||||
loadFiltered(selectedGenres, sort, losslessOnly);
|
||||
} else if (yearActive) {
|
||||
load(sort, 0, false, { from: fromNum, to: toNum });
|
||||
load(sort, 0, false, { from: fromNum, to: toNum }, losslessOnly);
|
||||
} else {
|
||||
load(sort, 0);
|
||||
load(sort, 0, false, undefined, losslessOnly);
|
||||
}
|
||||
}, [sort, genreFiltered, selectedGenres, yearActive, fromNum, toNum, load, loadFiltered]);
|
||||
}, [sort, genreFiltered, selectedGenres, yearActive, fromNum, toNum, losslessOnly, load, loadFiltered]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || !hasMore || genreFiltered) return;
|
||||
const next = page + 1;
|
||||
setPage(next);
|
||||
const yf = yearActive ? { from: fromNum, to: toNum } : undefined;
|
||||
load(sort, next * PAGE_SIZE, true, yf);
|
||||
}, [loading, hasMore, page, sort, load, genreFiltered, yearActive, fromNum, toNum]);
|
||||
load(sort, next * PAGE_SIZE, true, yf, losslessOnly);
|
||||
}, [
|
||||
loading,
|
||||
hasMore,
|
||||
page,
|
||||
sort,
|
||||
load,
|
||||
genreFiltered,
|
||||
yearActive,
|
||||
fromNum,
|
||||
toNum,
|
||||
losslessOnly,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!indexEnabled && losslessOnly) setLosslessOnly(false);
|
||||
}, [indexEnabled, losslessOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = observerTarget.current;
|
||||
@@ -301,6 +386,11 @@ export default function Albums() {
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, scrollBodyEl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingClientFilterMatch || loading) return;
|
||||
loadMore();
|
||||
}, [pendingClientFilterMatch, loading, loadMore]);
|
||||
|
||||
const sortOptions: { value: SortType; label: string }[] = [
|
||||
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
|
||||
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
|
||||
@@ -352,6 +442,10 @@ export default function Albums() {
|
||||
|
||||
<StarFilterButton active={starredOnly} onChange={setStarredOnly} />
|
||||
|
||||
{indexEnabled && (
|
||||
<LosslessFilterButton active={losslessOnly} onChange={setLosslessOnly} />
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`btn btn-surface${compFilter !== 'all' ? ' btn-sort-active' : ''}`}
|
||||
onClick={cycleCompFilter}
|
||||
@@ -406,14 +500,22 @@ export default function Albums() {
|
||||
perfFlags.disableMainstageVirtualLists,
|
||||
]}
|
||||
>
|
||||
{loading && albums.length === 0 ? (
|
||||
{(loading && albums.length === 0) || pendingClientFilterMatch ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : !loading && albums.length === 0 && !genreFiltered && !yearActive && !starredOnly && compFilter === 'all' ? (
|
||||
) : !loading && albums.length === 0 && !genreFiltered && !yearActive && !clientFilterActive && !losslessOnly ? (
|
||||
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
||||
{t('common.libraryEmpty')}
|
||||
</div>
|
||||
) : !loading && albums.length === 0 && losslessOnly ? (
|
||||
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
||||
{t('losslessAlbums.empty')}
|
||||
</div>
|
||||
) : !loading && visibleAlbums.length === 0 && clientFilterActive ? (
|
||||
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
||||
{visibleEmptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!perfFlags.disableMainstageGridCards && (
|
||||
@@ -433,6 +535,7 @@ export default function Albums() {
|
||||
<AlbumCard
|
||||
album={a}
|
||||
displayCssPx={albumCellDisplayCssPx}
|
||||
linkQuery={losslessOnly ? LOSSLESS_MODE_QUERY : undefined}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Play, Square, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2, ChevronDown, ChevronRight, ChevronUp, Share2, AudioLines } from 'lucide-react';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
@@ -37,21 +37,25 @@ import ArtistDetailHero from '../components/artistDetail/ArtistDetailHero';
|
||||
import ArtistDetailTopTracks from '../components/artistDetail/ArtistDetailTopTracks';
|
||||
import ArtistDetailSimilarArtists from '../components/artistDetail/ArtistDetailSimilarArtists';
|
||||
import ArtistCard from '../components/nowPlaying/ArtistCard';
|
||||
import LosslessModeBanner from '../components/LosslessModeBanner';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
||||
|
||||
|
||||
export default function ArtistDetail() {
|
||||
const { t } = useTranslation();
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const losslessOnly = searchParams.get('lossless') === '1';
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
artist, setArtist, albums, topSongs, info, featuredAlbums,
|
||||
loading, artistInfoLoading, featuredLoading,
|
||||
isStarred, setIsStarred,
|
||||
} = useArtistDetailData(id);
|
||||
} = useArtistDetailData(id, { losslessOnly });
|
||||
const [radioLoading, setRadioLoading] = useState(false);
|
||||
const [playAllLoading, setPlayAllLoading] = useState(false);
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
@@ -270,6 +274,8 @@ export default function ArtistDetail() {
|
||||
setHeaderCoverFailed={setHeaderCoverFailed}
|
||||
/>
|
||||
|
||||
{losslessOnly && <LosslessModeBanner />}
|
||||
|
||||
{/* User-reorderable sections — order + visibility configured in Settings.
|
||||
* Each case renders the same JSX it did pre-refactor; only `marginTop`
|
||||
* (now derived from the actual render order) and the outer wrapper changed. */}
|
||||
@@ -294,6 +300,7 @@ export default function ArtistDetail() {
|
||||
topSongs={topSongs}
|
||||
marginTop={sectionMt('topTracks')}
|
||||
playTopSongWithContinuation={playTopSongWithContinuation}
|
||||
losslessOnly={losslessOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -314,7 +321,9 @@ export default function ArtistDetail() {
|
||||
case 'albums': return (
|
||||
<Fragment key="albums">
|
||||
<h2 className="section-title" style={{ marginTop: sectionMt('albums'), marginBottom: '1rem' }}>
|
||||
{t('artistDetail.albumsBy', { name: artist.name })}
|
||||
{losslessOnly
|
||||
? t('artistDetail.albumsByLossless', { name: artist.name })
|
||||
: t('artistDetail.albumsBy', { name: artist.name })}
|
||||
</h2>
|
||||
{albums.length > 0 ? (
|
||||
groupedAlbums.length === 1 ? (
|
||||
@@ -326,7 +335,9 @@ export default function ArtistDetail() {
|
||||
layoutSignal={albums.length}
|
||||
wrapClassName="album-grid-wrap album-grid-wrap--artist"
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => <AlbumCard album={a} />}
|
||||
renderItem={a => (
|
||||
<AlbumCard album={a} linkQuery={losslessOnly ? LOSSLESS_MODE_QUERY : undefined} />
|
||||
)}
|
||||
/>
|
||||
) : groupedAlbums.map(([label, group]) => (
|
||||
<div key={label} className="artist-release-group">
|
||||
@@ -342,7 +353,9 @@ export default function ArtistDetail() {
|
||||
layoutSignal={group.length}
|
||||
wrapClassName="album-grid-wrap album-grid-wrap--artist"
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => <AlbumCard album={a} />}
|
||||
renderItem={a => (
|
||||
<AlbumCard album={a} linkQuery={losslessOnly ? LOSSLESS_MODE_QUERY : undefined} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { useCallback, useEffect, 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';
|
||||
@@ -22,16 +23,16 @@ 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 { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { runLocalLosslessAlbums } from '../utils/library/browseTextSearch';
|
||||
|
||||
/** 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;
|
||||
/** 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';
|
||||
@@ -43,6 +44,7 @@ export default function LosslessAlbums() {
|
||||
const auth = useAuthStore();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
@@ -52,6 +54,8 @@ export default function LosslessAlbums() {
|
||||
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 { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums);
|
||||
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
||||
@@ -59,17 +63,10 @@ export default function LosslessAlbums() {
|
||||
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. */
|
||||
/** Network pagination cursor — unused on the local path. */
|
||||
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 localOffset = useRef(0);
|
||||
const inFlight = useRef(false);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const scrollBodyRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -85,62 +82,97 @@ export default function LosslessAlbums() {
|
||||
activeServerId,
|
||||
]);
|
||||
|
||||
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 page = await runLocalLosslessAlbums(serverId, LOCAL_PAGE_SIZE, localOffset.current);
|
||||
if (!page) return null;
|
||||
localOffset.current += page.albums.length;
|
||||
return page;
|
||||
}, [serverId]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (inFlight.current) return;
|
||||
if (inFlight.current || useLocalIndex === null) 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);
|
||||
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 {
|
||||
setUnsupported(true);
|
||||
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 {
|
||||
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 (indexEnabled && serverId) {
|
||||
const local = await runLocalLosslessAlbums(serverId, LOCAL_PAGE_SIZE, 0);
|
||||
if (cancelled) return;
|
||||
if (local) {
|
||||
setUseLocalIndex(true);
|
||||
localOffset.current = local.albums.length;
|
||||
setAlbums(local.albums);
|
||||
setHasMore(local.hasMore);
|
||||
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 {
|
||||
@@ -150,10 +182,10 @@ export default function LosslessAlbums() {
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [activeServerId]);
|
||||
}, [activeServerId, indexEnabled, loadMoreNetwork, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore) return;
|
||||
if (!hasMore || useLocalIndex === null) return;
|
||||
const node = observerTarget.current;
|
||||
if (!node) return;
|
||||
const root = scrollBodyRef.current;
|
||||
@@ -166,7 +198,7 @@ export default function LosslessAlbums() {
|
||||
);
|
||||
obs.observe(node);
|
||||
return () => obs.disconnect();
|
||||
}, [hasMore, loadMore, loading, albums.length, scrollBodyEl]);
|
||||
}, [hasMore, loadMore, loading, albums.length, scrollBodyEl, useLocalIndex]);
|
||||
|
||||
const handleEnqueueSelected = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
@@ -232,7 +264,7 @@ export default function LosslessAlbums() {
|
||||
? t('albums.selectionCount', { count: selectedIds.size })
|
||||
: t('home.losslessAlbums')}
|
||||
</h1>
|
||||
{!(selectionMode && selectedIds.size > 0) && (
|
||||
{!(selectionMode && selectedIds.size > 0) && useLocalIndex === false && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: 1.3 }}>
|
||||
{t('losslessAlbums.slowFetchHint')}
|
||||
</p>
|
||||
@@ -282,6 +314,7 @@ export default function LosslessAlbums() {
|
||||
albums.length,
|
||||
hasMore,
|
||||
selectionMode,
|
||||
useLocalIndex,
|
||||
perfFlags.disableMainstageVirtualLists,
|
||||
perfFlags.disableMainstageStickyHeader,
|
||||
]}
|
||||
@@ -311,6 +344,7 @@ export default function LosslessAlbums() {
|
||||
renderItem={a => (
|
||||
<AlbumCard
|
||||
album={a}
|
||||
linkQuery={LOSSLESS_MODE_QUERY}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
|
||||
Reference in New Issue
Block a user