mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +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:
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user