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:
cucadmuh
2026-05-27 00:02:46 +03:00
committed by GitHub
parent 418b25914a
commit a8cfff0b62
65 changed files with 1615 additions and 138 deletions
+4 -1
View File
@@ -32,6 +32,8 @@ interface AlbumCardProps {
displayCssPx?: number;
/** @deprecated Use displayCssPx — kept for call-site transition only */
artworkSize?: number;
/** Appended to `/album/:id`, e.g. `lossless=1`. */
linkQuery?: string;
/** In-page scroll viewport (`VirtualCardGrid` `scrollRootId`) for cover IO priority. */
observeScrollRootId?: string;
/** `high` for bounded grids (Random Albums, …) — skip defer-until-visible. */
@@ -50,6 +52,7 @@ function AlbumCard({
artworkSize: _artworkSize,
observeScrollRootId,
ensurePriority,
linkQuery,
}: AlbumCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -72,7 +75,7 @@ function AlbumCard({
const handleClick = (opts?: { shiftKey?: boolean }) => {
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
navigate(`/album/${album.id}`);
navigate(linkQuery ? `/album/${album.id}?${linkQuery}` : `/album/${album.id}`);
};
return (
+4
View File
@@ -22,6 +22,8 @@ interface Props {
artworkSize?: number;
windowArtworkByViewport?: boolean;
initialArtworkBudget?: number;
/** Appended to `/album/:id` links, e.g. `lossless=1`. */
albumLinkQuery?: string;
}
export default function AlbumRow({
@@ -38,6 +40,7 @@ export default function AlbumRow({
artworkSize,
windowArtworkByViewport = false,
initialArtworkBudget = 8,
albumLinkQuery,
}: Props) {
const perfFlags = usePerfProbeFlags();
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
@@ -167,6 +170,7 @@ export default function AlbumRow({
key={a.id}
album={a}
showRating={showRating}
linkQuery={albumLinkQuery}
disableArtwork={
artworkDisabled ||
(windowArtworkByViewport && idx >= artworkBudget)
+5 -2
View File
@@ -9,15 +9,18 @@ import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
interface Props {
artist: SubsonicArtist;
/** Appended to `/artist/:id`, e.g. `lossless=1`. */
linkQuery?: string;
}
export default function ArtistCardLocal({ artist }: Props) {
export default function ArtistCardLocal({ artist, linkQuery }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const coverId = coverArtIdFromArtist(artist);
const href = linkQuery ? `/artist/${artist.id}?${linkQuery}` : `/artist/${artist.id}`;
return (
<div className="artist-card" onClick={() => navigate(`/artist/${artist.id}`)}>
<div className="artist-card" onClick={() => navigate(href)}>
<div className="artist-card-avatar">
{artist.coverArt || artist.id ? (
<CoverArtImage
+3 -2
View File
@@ -9,9 +9,10 @@ interface Props {
artists: SubsonicArtist[];
moreLink?: string;
moreText?: string;
artistLinkQuery?: string;
}
export default function ArtistRow({ title, artists, moreLink, moreText }: Props) {
export default function ArtistRow({ title, artists, moreLink, moreText, artistLinkQuery }: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const [showLeft, setShowLeft] = useState(false);
@@ -54,7 +55,7 @@ export default function ArtistRow({ title, artists, moreLink, moreText }: Props)
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{artists.map(a => <ArtistCardLocal key={a.id} artist={a} />)}
{artists.map(a => <ArtistCardLocal key={a.id} artist={a} linkQuery={artistLinkQuery} />)}
{moreLink && (
<div className="album-card-more" onClick={() => navigate(moreLink)}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: 'var(--radius-sm)' }}>
+14 -1
View File
@@ -4,6 +4,9 @@ import { useTranslation } from 'react-i18next';
import { ndListLosslessAlbumsPage } from '../api/navidromeBrowse';
import AlbumRow from './AlbumRow';
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { runLocalLosslessAlbums } from '../utils/library/browseTextSearch';
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
interface Props {
disableArtwork?: boolean;
@@ -22,11 +25,20 @@ export default function LosslessAlbumsRail({
}: Props) {
const { t } = useTranslation();
const activeServerId = useAuthStore(s => s.activeServerId);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(activeServerId ?? ''));
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
if (indexEnabled && activeServerId) {
const local = await runLocalLosslessAlbums(activeServerId, TARGET_ALBUMS, 0);
if (cancelled) return;
if (local && local.albums.length > 0) {
setAlbums(local.albums);
return;
}
}
try {
const page = await ndListLosslessAlbumsPage({ targetNewAlbums: TARGET_ALBUMS });
if (cancelled) return;
@@ -36,7 +48,7 @@ export default function LosslessAlbumsRail({
}
})();
return () => { cancelled = true; };
}, [activeServerId]);
}, [activeServerId, indexEnabled]);
if (albums.length === 0) return null;
@@ -49,6 +61,7 @@ export default function LosslessAlbumsRail({
artworkSize={artworkSize}
windowArtworkByViewport={windowArtworkByViewport}
initialArtworkBudget={initialArtworkBudget}
albumLinkQuery={LOSSLESS_MODE_QUERY}
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { Gem } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface Props {
active: boolean;
onChange: (next: boolean) => void;
}
export default function LosslessFilterButton({ active, onChange }: Props) {
const { t } = useTranslation();
const tooltip = active ? t('albums.losslessTooltipOn') : t('albums.losslessTooltipOff');
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {};
return (
<button
type="button"
className={`btn btn-surface${active ? ' btn-sort-active' : ''}`}
onClick={() => onChange(!active)}
aria-pressed={active}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem', ...activeStyle,
}}
>
<Gem size={14} />
{t('albums.losslessLabel')}
</button>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { Gem } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
export default function LosslessModeBanner() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const handleExit = () => {
const params = new URLSearchParams(location.search);
params.delete('lossless');
const qs = params.toString();
navigate({ pathname: location.pathname, search: qs ? `?${qs}` : '' }, { replace: true });
};
return (
<div className="lossless-mode-banner" role="status">
<Gem size={16} aria-hidden className="lossless-mode-banner__icon" />
<span>{t('losslessAlbums.modeBanner')}</span>
<button type="button" className="btn btn-ghost lossless-mode-banner__exit" onClick={handleExit}>
{t('losslessAlbums.modeBannerExit')}
</button>
</div>
);
}
@@ -13,9 +13,12 @@ interface Props {
topSongs: SubsonicSong[];
marginTop: string;
playTopSongWithContinuation: (startIndex: number) => Promise<void>;
losslessOnly?: boolean;
}
export default function ArtistDetailTopTracks({ topSongs, marginTop, playTopSongWithContinuation }: Props) {
export default function ArtistDetailTopTracks({
topSongs, marginTop, playTopSongWithContinuation, losslessOnly = false,
}: Props) {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -27,7 +30,7 @@ export default function ArtistDetailTopTracks({ topSongs, marginTop, playTopSong
return (
<Fragment>
<h2 className="section-title" style={{ marginTop, marginBottom: '1rem' }}>
{t('artistDetail.topTracks')}
{t(losslessOnly ? 'artistDetail.topTracksLossless' : 'artistDetail.topTracks')}
</h2>
<div className="tracklist" data-preview-loc="artist" style={{ padding: 0, marginBottom: '2rem' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}>
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { GripVertical } from 'lucide-react';
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
import { useAuthStore } from '../../store/authStore';
import { useSidebarStore, SidebarItemConfig } from '../../store/sidebarStore';
import { useSidebarStore, SidebarItemConfig, CONSERVED_SIDEBAR_NAV_IDS } from '../../store/sidebarStore';
import { useLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable';
import { ALL_NAV_ITEMS } from '../../config/navItems';
import { applySidebarDropReorder } from '../../utils/componentHelpers/sidebarNavReorder';
@@ -43,6 +43,7 @@ export function SidebarCustomizer() {
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
const libraryItems = items.filter(cfg => {
if (CONSERVED_SIDEBAR_NAV_IDS.has(cfg.id)) return false;
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums' || cfg.id === 'luckyMix')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;