Linux: session-native GDK/WebKit mitigations and in-page browse scroll (#731)

* feat(linux): session GDK defaults, nvidia-quirk, optional x11-legacy wrap

Ship PSYSONIC_ALLOW_NATIVE_GDK from Nix/AUR instead of pinning WEBKIT_DISABLE_*
and GDK x11. Add flake psysonic-x11-legacy for the old wrap; alias gdk-session
to psysonic. Startup uses webkit2gtk-nvidia-quirk and Wayland-aware compositing;
refresh Help (a45) and nixos-install docs.

* fix(linux): session GDK and nvidia-quirk only; drop wrapper env heuristics

Remove PSYSONIC_ALLOW_NATIVE_GDK and devShell GDK/WEBKIT exports; stop
synthesizing GDK/WebKit vars in main.rs. Update Nix/AUR wrappers, install
docs, CHANGELOG, and help FAQ with practical user-facing workarounds.

* fix(linux): X11-pinned GDK uses DMABUF quirk path, not Wayland explicit-sync

When GDK_BACKEND is forced to x11 on a wayland user session, webkit2gtk-nvidia-quirk
would still apply __NV_DISABLE_EXPLICIT_SYNC and gray out the webview. Map that case
to WEBKIT_DISABLE_DMABUF_RENDERER like native X11.

* fix(ui): stabilize WebKitGTK/Wayland hover paint for nav and media cards

Sidebar nav links avoid transition:all and promote icons with translateZ(0).
Artist rows and album/artist/song cards use compositing hints; card shadows
and borders no longer interpolate so cover zoom can stay smooth without jitter.

* fix(ui): isolate artist/album card text and cover paint on WebKitGTK

Promote cover blocks with contain/paint and text stacks with translateZ(0);
use artist-card-info on the artists grid for the same layout as other cards.

* feat(artists): in-page overlay scroll and locked main viewport

Move list/grid into an inner OverlayScrollArea, stop sticky toolbar from
owning the route scroll, align the rail with the main panel edge, and skip
the main-route overlay thumb when the viewport cannot scroll vertically.

* feat(browse): extend in-page overlay scroll to more library routes

Reuse the locked main viewport pattern from Artists for Albums, Composers,
Lossless albums, and New releases; wire VirtualCardGrid and scroll chrome
to the matching in-page viewport ids.

* fix(linux): improve Wayland GPU compositing text clarity in WebKitGTK

Use on-demand hardware acceleration on main and mini webviews when the
session is Wayland and compositing stays on; gate subpixel body AA on the
same conditions via new Tauri probes. Document PSYSONIC_SKIP_WAYLAND_FONT_TUNING
for opt-out and changelog.

* fix(rust): satisfy clippy needless_return in Linux webkit helpers

* fix(linux): tune Wayland text rendering with HW policy env and CSS

Allow PSYSONIC_WEBKIT_WAYLAND_HW_POLICY to select WebKit hardware
acceleration policy (never/always vs default on-demand). Extend Wayland
font CSS to #root with geometricPrecision and text-size-adjust on html.

* feat(linux): Wayland text presets in settings, safe WebKit apply, CPU default

Persist profile to app config; apply WebKit policy at startup/mini only to
avoid WebKitGTK hangs on live toggles. UI + CSS preview stays live; default
preset is sharp (CPU-friendly).

* fix(linux): map Wayland sharp preset to OnDemand WebKit policy

HardwareAccelerationPolicy::Never at startup broke main-viewport wheel
scrolling on WebKitGTK+Wayland; sharp vs balanced remains a CSS AA path.
Use PSYSONIC_WEBKIT_WAYLAND_HW_POLICY for a true Never policy.

* fix(rust): gate Linux-only Wayland WebKit helpers for Windows builds

Re-export startup helpers only under cfg(linux) and drop non-Linux stubs so
Windows compiles without unused-import and dead-code warnings.

* chore(release): CHANGELOG + credits for Linux session/WebKit work (PR #731)

Consolidate scattered incremental changelog notes into two [1.47.0]
entries with PR link; remove duplicate Linux blocks from [1.46.0] Fixed.
Append settings credit line for cucadmuh.
This commit is contained in:
cucadmuh
2026-05-18 21:00:46 +03:00
committed by GitHub
parent b4782aeedb
commit 70c2fdfbf9
61 changed files with 1602 additions and 712 deletions
+159 -115
View File
@@ -4,7 +4,7 @@ import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import { dedupeById } from '../utils/dedupeById';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import YearFilterButton from '../components/YearFilterButton';
@@ -19,10 +19,13 @@ import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/ui/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { CheckSquare2, Download, HardDriveDownload, ListMusic, Disc3, ListPlus } from 'lucide-react';
import { CheckSquare2, Download, HardDriveDownload, Disc3, ListPlus } from 'lucide-react';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { useRangeSelection } from '../hooks/useRangeSelection';
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
import OverlayScrollArea from '../components/OverlayScrollArea';
import { ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
type CompFilter = 'all' | 'only' | 'hide';
@@ -58,6 +61,12 @@ export default function Albums() {
const [compFilter, setCompFilter] = useState<CompFilter>('all');
const [starredOnly, setStarredOnly] = useState(false);
const observerTarget = useRef<HTMLDivElement>(null);
const scrollBodyRef = useRef<HTMLDivElement | null>(null);
const [scrollBodyEl, setScrollBodyEl] = useState<HTMLDivElement | null>(null);
const bindAlbumsScrollBody = useCallback((el: HTMLDivElement | null) => {
scrollBodyRef.current = el;
setScrollBodyEl(el);
}, []);
// ── Multi-selection ──────────────────────────────────────────────────────
// selectedIds + toggleSelect come from useRangeSelection (declared after
@@ -88,7 +97,6 @@ export default function Albums() {
};
const selectedAlbums = visibleAlbums.filter(a => selectedIds.has(a.id));
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const enqueue = usePlayerStore(state => state.enqueue);
const handleEnqueueSelected = async () => {
@@ -155,6 +163,18 @@ export default function Albums() {
const toNum = parseInt(yearTo, 10);
const yearActive = !isNaN(fromNum) && !isNaN(toNum) && fromNum >= 1 && toNum >= 1;
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
sort,
genreFiltered,
yearActive,
yearFrom,
yearTo,
compFilter,
starredOnly,
selectionMode,
selectedGenres,
]);
const load = useCallback(async (
sortType: SortType,
offset: number,
@@ -210,15 +230,19 @@ export default function Albums() {
}, [loading, hasMore, page, sort, load, genreFiltered, yearActive, fromNum, toNum]);
useEffect(() => {
const node = observerTarget.current;
if (!node) return;
const root = scrollBodyRef.current;
const observer = new IntersectionObserver(
entries => { if (entries[0].isIntersecting) loadMore(); },
// Prefetch ~1.5 screens ahead so the user never visibly waits at the
// bottom of the grid. 200px caught the user at the very edge.
{ rootMargin: '1500px' }
entries => { if (entries[0]?.isIntersecting) loadMore(); },
{
root: root instanceof HTMLElement ? root : null,
rootMargin: '1500px',
},
);
if (observerTarget.current) observer.observe(observerTarget.current);
observer.observe(node);
return () => observer.disconnect();
}, [loadMore]);
}, [loadMore, scrollBodyEl]);
const sortOptions: { value: SortType; label: string }[] = [
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
@@ -226,122 +250,142 @@ export default function Albums() {
];
return (
<div className="content-body animate-fade-in">
<div className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}>
{!perfFlags.disableMainstageStickyHeader && (
<div className="page-sticky-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size })
: t('albums.title')}
</h1>
<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>
</>
) : (
<>
{!yearActive && (
<SortDropdown
value={sort}
options={sortOptions}
onChange={setSort}
<div className="mainstage-inpage-toolbar">
<div className="page-sticky-header mainstage-inpage-toolbar-row">
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size })
: t('albums.title')}
</h1>
<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>
</>
) : (
<>
{!yearActive && (
<SortDropdown
value={sort}
options={sortOptions}
onChange={setSort}
/>
)}
<YearFilterButton
from={yearFrom}
to={yearTo}
onChange={(from, to) => { setYearFrom(from); setYearTo(to); }}
/>
)}
<YearFilterButton
from={yearFrom}
to={yearTo}
onChange={(from, to) => { setYearFrom(from); setYearTo(to); }}
/>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
<StarFilterButton active={starredOnly} onChange={setStarredOnly} />
<StarFilterButton active={starredOnly} onChange={setStarredOnly} />
<button
className={`btn btn-surface${compFilter !== 'all' ? ' btn-sort-active' : ''}`}
onClick={cycleCompFilter}
data-tooltip={
compFilter === 'all' ? t('albums.compilationTooltipAll')
: compFilter === 'only' ? t('albums.compilationTooltipOnly')
: t('albums.compilationTooltipHide')
}
data-tooltip-pos="bottom"
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
...(compFilter !== 'all' ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
}}
>
<Disc3 size={14} />
{compFilter === 'all' ? t('albums.compilationLabel')
: compFilter === 'only' ? t('albums.compilationOnly')
: t('albums.compilationHide')}
</button>
</>
)}
<button
className={`btn btn-surface${compFilter !== 'all' ? ' btn-sort-active' : ''}`}
onClick={cycleCompFilter}
data-tooltip={
compFilter === 'all' ? t('albums.compilationTooltipAll')
: compFilter === 'only' ? t('albums.compilationTooltipOnly')
: t('albums.compilationTooltipHide')
}
data-tooltip-pos="bottom"
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
...(compFilter !== 'all' ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
}}
>
<Disc3 size={14} />
{compFilter === 'all' ? t('albums.compilationLabel')
: compFilter === 'only' ? t('albums.compilationOnly')
: t('albums.compilationHide')}
</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>
<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>
)}
{loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" />
</div>
) : !loading && albums.length === 0 && !genreFiltered && !yearActive && !starredOnly && compFilter === 'all' ? (
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{t('common.libraryEmpty')}
</div>
) : (
<>
{!perfFlags.disableMainstageGridCards && (
<VirtualCardGrid
items={visibleAlbums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={visibleAlbums.length}
renderItem={a => (
<AlbumCard
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
)}
/>
)}
{!genreFiltered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
</>
)}
<OverlayScrollArea
className="mainstage-inpage-scroll"
viewportClassName="mainstage-inpage-scroll__viewport"
viewportId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
viewportRef={bindAlbumsScrollBody}
railInset="panel"
measureDeps={[
loading,
visibleAlbums.length,
genreFiltered,
hasMore,
selectionMode,
sort,
perfFlags.disableMainstageGridCards,
perfFlags.disableMainstageVirtualLists,
]}
>
{loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" />
</div>
) : !loading && albums.length === 0 && !genreFiltered && !yearActive && !starredOnly && compFilter === 'all' ? (
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{t('common.libraryEmpty')}
</div>
) : (
<>
{!perfFlags.disableMainstageGridCards && (
<VirtualCardGrid
items={visibleAlbums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={visibleAlbums.length}
scrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
renderItem={a => (
<AlbumCard
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
)}
/>
)}
{!genreFiltered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
</>
)}
</OverlayScrollArea>
</div>
);
}
+175 -132
View File
@@ -4,12 +4,13 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { LayoutGrid, List, Images, CheckSquare2 } from 'lucide-react';
import StarFilterButton from '../components/StarFilterButton';
import OverlayScrollArea from '../components/OverlayScrollArea';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
import { APP_MAIN_SCROLL_VIEWPORT_ID, ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById, useElementClientHeightForElement } from '../hooks/useResizeClientHeight';
import { useCardGridMetrics } from '../hooks/useCardGridMetrics';
import { useRemeasureGridVirtualizer } from '../hooks/useRemeasureGridVirtualizer';
import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin';
@@ -22,6 +23,7 @@ import {
ARTIST_LIST_ROW_EST,
} from '../utils/componentHelpers/artistsHelpers';
import { useArtistsFiltering } from '../hooks/useArtistsFiltering';
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
import { useArtistsInfiniteScroll } from '../hooks/useArtistsInfiniteScroll';
import { ArtistsGridView } from '../components/artists/ArtistsGridView';
import { ArtistsListView } from '../components/artists/ArtistsListView';
@@ -36,6 +38,14 @@ export default function Artists() {
const [starredOnly, setStarredOnly] = useState(false);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const artistsScrollBodyRef = useRef<HTMLDivElement | null>(null);
const [artistsScrollBodyEl, setArtistsScrollBodyEl] = useState<HTMLDivElement | null>(null);
const bindArtistsScrollBody = useCallback((el: HTMLDivElement | null) => {
artistsScrollBodyRef.current = el;
setArtistsScrollBodyEl(el);
}, []);
const getArtistsScrollRoot = useCallback(() => artistsScrollBodyRef.current, []);
const showArtistImages = useAuthStore(s => s.showArtistImages);
const PAGE_SIZE = showArtistImages ? 50 : 100; // Smaller with images to reduce I/O
const {
@@ -45,6 +55,7 @@ export default function Artists() {
} = useArtistsInfiniteScroll({
pageSize: PAGE_SIZE,
resetDeps: [filter, letterFilter, starredOnly, viewMode],
getScrollRoot: getArtistsScrollRoot,
});
const navigate = useNavigate();
const openContextMenu = usePlayerStore(state => state.openContextMenu);
@@ -68,11 +79,6 @@ export default function Artists() {
});
}, []);
const clearSelection = () => {
setSelectionMode(false);
setSelectedIds(new Set());
};
const selectedArtists = artists.filter(a => selectedIds.has(a.id));
useEffect(() => {
@@ -83,7 +89,25 @@ export default function Artists() {
filtered, visible, hasMore, groups, letters, artistListFlatRows,
} = useArtistsFiltering({ artists, filter, letterFilter, starredOnly, visibleCount, viewMode });
const mainstageHeaderTight = useMainstageInpageHeaderTight(artistsScrollBodyEl, [
filter,
letterFilter,
starredOnly,
viewMode,
]);
const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
const artistsInpageScrollHeight = useElementClientHeightForElement(
artistsScrollBodyEl,
mainScrollViewportHeight,
);
const getInpageScrollElement = useCallback(
() =>
artistsScrollBodyRef.current
?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null),
[],
);
const artistGridMeasureRef = useRef<HTMLDivElement>(null);
const { gridCols: artistGridCols, rowHeightEst: artistGridRowHeightEst } = useCardGridMetrics(
@@ -97,7 +121,7 @@ export default function Artists() {
const artistGridOverscan = Math.max(
2,
Math.ceil(mainScrollViewportHeight / Math.max(1, artistGridRowHeightEst)),
Math.ceil(artistsInpageScrollHeight / Math.max(1, artistGridRowHeightEst)),
);
const artistGridScrollMargin = useVirtualizerScrollMargin(
@@ -114,7 +138,7 @@ export default function Artists() {
perfFlags.disableMainstageVirtualLists || viewMode !== 'grid'
? 0
: artistVirtualRowCount,
getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
getScrollElement: getInpageScrollElement,
estimateSize: () => artistGridRowHeightEst,
overscan: artistGridOverscan,
scrollMargin: artistGridScrollMargin,
@@ -127,10 +151,9 @@ export default function Artists() {
virtualRowCount: artistVirtualRowCount,
});
/** Mixed row heights; smallest typical step ≈ artist row — one viewport of extra indices each side. */
const artistListOverscan = Math.max(
12,
Math.ceil(mainScrollViewportHeight / ARTIST_LIST_ROW_EST),
Math.ceil(artistsInpageScrollHeight / ARTIST_LIST_ROW_EST),
);
const artistListWrapRef = useRef<HTMLDivElement>(null);
@@ -146,14 +169,13 @@ export default function Artists() {
const artistListVirtualizer = useVirtualizer({
count:
perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : artistListFlatRows.length,
getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
getScrollElement: getInpageScrollElement,
estimateSize: index => {
const row = artistListFlatRows[index];
if (!row) return ARTIST_LIST_ROW_EST;
if (row.kind === 'letter') return ARTIST_LIST_LETTER_ROW_EST;
return row.isLastInLetter ? ARTIST_LIST_LAST_IN_LETTER_EST : ARTIST_LIST_ROW_EST;
},
/** Stable keys — avoids row DOM reuse glitches when the filtered slice changes. */
getItemKey: index => {
const row = artistListFlatRows[index];
if (!row) return index;
@@ -165,135 +187,156 @@ export default function Artists() {
});
return (
<div className="content-body animate-fade-in">
<div className="page-sticky-header">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('artists.selectionCount', { count: selectedIds.size })
: t('artists.title')}
</h1>
<input
className="input"
style={{ maxWidth: 220 }}
placeholder={t('artists.search')}
value={filter}
onChange={e => setFilter(e.target.value)}
id="artist-filter-input"
/>
<div
className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}
>
<div className="mainstage-inpage-toolbar">
<div className="page-sticky-header">
<div className="mainstage-inpage-toolbar-row">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('artists.selectionCount', { count: selectedIds.size })
: t('artists.title')}
</h1>
<input
className="input"
style={{ maxWidth: 220 }}
placeholder={t('artists.search')}
value={filter}
onChange={e => setFilter(e.target.value)}
id="artist-filter-input"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{!(selectionMode && selectedIds.size > 0) && (<>
<StarFilterButton size="compact" active={starredOnly} onChange={setStarredOnly} />
<button
className={`btn btn-surface`}
onClick={() => setShowArtistImages(!showArtistImages)}
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
data-tooltip-wrap
>
<Images size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.gridView')}
>
<LayoutGrid size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('list')}
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.listView')}
>
<List size={20} />
</button>
</>
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('artists.cancelSelect') : t('artists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
</button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{!(selectionMode && selectedIds.size > 0) && (<>
<StarFilterButton size="compact" active={starredOnly} onChange={setStarredOnly} />
<button
className={`btn btn-surface`}
onClick={() => setShowArtistImages(!showArtistImages)}
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
data-tooltip-wrap
>
<Images size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.gridView')}
>
<LayoutGrid size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('list')}
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.listView')}
>
<List size={20} />
</button>
</>
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('artists.cancelSelect') : t('artists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
</button>
<div className="mainstage-inpage-toolbar-alpha-row">
{ALPHABET.map(l => (
<button
key={l}
onClick={() => setLetterFilter(l)}
className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`}
>
{l === ALL_SENTINEL ? t('artists.all') : l}
</button>
))}
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: 'var(--space-4)' }}>
{ALPHABET.map(l => (
<button
key={l}
onClick={() => setLetterFilter(l)}
className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`}
>
{l === ALL_SENTINEL ? t('artists.all') : l}
</button>
))}
</div>
</div>
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
<OverlayScrollArea
className="mainstage-inpage-scroll"
viewportClassName="mainstage-inpage-scroll__viewport"
viewportId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID}
viewportRef={bindArtistsScrollBody}
railInset="panel"
measureDeps={[
loading,
viewMode,
visible.length,
artistListFlatRows.length,
filtered.length,
hasMore,
selectionMode,
]}
>
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
{!loading && viewMode === 'grid' && (
<ArtistsGridView
visible={visible}
gridCols={artistGridCols}
measureRef={artistGridMeasureRef}
virtualization={
perfFlags.disableMainstageVirtualLists
? null
: { virtualizer: artistGridVirtualizer, scrollMargin: artistGridScrollMargin }
}
selectionMode={selectionMode}
selectedIds={selectedIds}
selectedArtists={selectedArtists}
showArtistImages={showArtistImages}
toggleSelect={toggleSelect}
navigate={navigate}
openContextMenu={openContextMenu}
t={t}
/>
)}
{!loading && viewMode === 'grid' && (
<ArtistsGridView
visible={visible}
gridCols={artistGridCols}
measureRef={artistGridMeasureRef}
virtualization={
perfFlags.disableMainstageVirtualLists
? null
: { virtualizer: artistGridVirtualizer, scrollMargin: artistGridScrollMargin }
}
selectionMode={selectionMode}
selectedIds={selectedIds}
selectedArtists={selectedArtists}
showArtistImages={showArtistImages}
toggleSelect={toggleSelect}
navigate={navigate}
openContextMenu={openContextMenu}
t={t}
/>
)}
{!loading && viewMode === 'list' && (
<ArtistsListView
virtualized={!perfFlags.disableMainstageVirtualLists}
groups={groups}
letters={letters}
artistListFlatRows={artistListFlatRows}
artistListVirtualizer={artistListVirtualizer}
artistListWrapRef={artistListWrapRef}
artistListScrollMargin={artistListScrollMargin}
selectionMode={selectionMode}
selectedIds={selectedIds}
selectedArtists={selectedArtists}
showArtistImages={showArtistImages}
toggleSelect={toggleSelect}
navigate={navigate}
openContextMenu={openContextMenu}
t={t}
/>
)}
{!loading && viewMode === 'list' && (
<ArtistsListView
virtualized={!perfFlags.disableMainstageVirtualLists}
groups={groups}
letters={letters}
artistListFlatRows={artistListFlatRows}
artistListVirtualizer={artistListVirtualizer}
artistListWrapRef={artistListWrapRef}
artistListScrollMargin={artistListScrollMargin}
selectionMode={selectionMode}
selectedIds={selectedIds}
selectedArtists={selectedArtists}
showArtistImages={showArtistImages}
toggleSelect={toggleSelect}
navigate={navigate}
openContextMenu={openContextMenu}
t={t}
/>
)}
{!loading && hasMore && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loadingMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
{!loading && hasMore && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loadingMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
{!loading && filtered.length === 0 && (
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
{t('artists.notFound')}
</div>
)}
{!loading && filtered.length === 0 && (
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
{t('artists.notFound')}
</div>
)}
</OverlayScrollArea>
</div>
);
}
+226 -174
View File
@@ -1,5 +1,5 @@
import type { SubsonicArtist } from '../api/subsonicTypes';
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { ndListArtistsByRole } from '../api/navidromeBrowse';
import { LayoutGrid, List } from 'lucide-react';
@@ -8,10 +8,12 @@ import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
import { APP_MAIN_SCROLL_VIEWPORT_ID, COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById, useElementClientHeightForElement } from '../hooks/useResizeClientHeight';
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
import OverlayScrollArea from '../components/OverlayScrollArea';
import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin';
const ALL_SENTINEL = 'ALL';
@@ -79,6 +81,12 @@ export default function Composers() {
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [loadingMore, setLoadingMore] = useState(false);
const observerTarget = useRef<HTMLDivElement>(null);
const scrollBodyRef = useRef<HTMLDivElement | null>(null);
const [scrollBodyEl, setScrollBodyEl] = useState<HTMLDivElement | null>(null);
const bindComposersScrollBody = useCallback((el: HTMLDivElement | null) => {
scrollBodyRef.current = el;
setScrollBodyEl(el);
}, []);
const navigate = useNavigate();
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
@@ -147,13 +155,19 @@ export default function Composers() {
const hasMore = visibleCount < filtered.length;
useEffect(() => {
const node = observerTarget.current;
if (!node) return;
const root = scrollBodyRef.current;
const observer = new IntersectionObserver(
entries => { if (entries[0].isIntersecting) loadMore(); },
{ rootMargin: '200px' }
entries => { if (entries[0]?.isIntersecting) loadMore(); },
{
root: root instanceof HTMLElement ? root : null,
rootMargin: '200px',
},
);
if (observerTarget.current) observer.observe(observerTarget.current);
observer.observe(node);
return () => observer.disconnect();
}, [loadMore, hasMore]);
}, [loadMore, hasMore, scrollBodyEl]);
const { groups, letters } = useMemo(() => {
if (viewMode !== 'list') return { groups: {} as Record<string, SubsonicArtist[]>, letters: [] as string[] };
@@ -181,9 +195,21 @@ export default function Composers() {
}, [viewMode, letters, groups]);
const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
const composersInpageScrollHeight = useElementClientHeightForElement(
scrollBodyEl,
mainScrollViewportHeight,
);
const getInpageScrollElement = useCallback(
() =>
scrollBodyRef.current
?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null),
[],
);
const composerListOverscan = Math.max(
12,
Math.ceil(mainScrollViewportHeight / COMPOSER_LIST_ROW_EST),
Math.ceil(composersInpageScrollHeight / COMPOSER_LIST_ROW_EST),
);
const composerListWrapRef = useRef<HTMLDivElement>(null);
@@ -199,7 +225,7 @@ export default function Composers() {
const composerListVirtualizer = useVirtualizer({
count:
perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : composerListFlatRows.length,
getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
getScrollElement: getInpageScrollElement,
estimateSize: index => {
const row = composerListFlatRows[index];
if (!row) return COMPOSER_LIST_ROW_EST;
@@ -216,6 +242,13 @@ export default function Composers() {
scrollMargin: composerListScrollMargin,
});
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
filter,
letterFilter,
starredOnly,
viewMode,
]);
if (loadError) {
return (
<div className="content-body animate-fade-in">
@@ -237,130 +270,164 @@ export default function Composers() {
}
return (
<div className="content-body animate-fade-in">
<div className="page-sticky-header">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('composers.title')}</h1>
<input
className="input"
style={{ maxWidth: 220 }}
placeholder={t('composers.search')}
value={filter}
onChange={e => setFilter(e.target.value)}
id="composer-filter-input"
/>
<div className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}>
<div className="mainstage-inpage-toolbar">
<div className="page-sticky-header">
<div className="mainstage-inpage-toolbar-row">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('composers.title')}</h1>
<input
className="input"
style={{ maxWidth: 220 }}
placeholder={t('composers.search')}
value={filter}
onChange={e => setFilter(e.target.value)}
id="composer-filter-input"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<StarFilterButton size="compact" active={starredOnly} onChange={setStarredOnly} />
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.gridView')}
>
<LayoutGrid size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('list')}
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.listView')}
>
<List size={20} />
</button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<StarFilterButton size="compact" active={starredOnly} onChange={setStarredOnly} />
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.gridView')}
>
<LayoutGrid size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('list')}
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.listView')}
>
<List size={20} />
</button>
<div className="mainstage-inpage-toolbar-alpha-row">
{ALPHABET.map(l => (
<button
key={l}
onClick={() => setLetterFilter(l)}
className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`}
>
{l === ALL_SENTINEL ? t('artists.all') : l}
</button>
))}
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: 'var(--space-4)' }}>
{ALPHABET.map(l => (
<button
key={l}
onClick={() => setLetterFilter(l)}
className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`}
>
{l === ALL_SENTINEL ? t('artists.all') : l}
</button>
))}
</div>
</div>
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
<OverlayScrollArea
className="mainstage-inpage-scroll"
viewportClassName="mainstage-inpage-scroll__viewport"
viewportId={COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID}
viewportRef={bindComposersScrollBody}
railInset="panel"
measureDeps={[
loading,
viewMode,
visible.length,
composerListFlatRows.length,
filtered.length,
hasMore,
]}
>
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
{!loading && viewMode === 'grid' && (
<VirtualCardGrid
items={visible}
itemKey={(a, _i) => a.id}
rowVariant="composer"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={visible.length}
wrapClassName="composer-grid-wrap"
gridGap="var(--space-2)"
renderItem={artist => (
<div
className="composer-card"
onClick={() => navigate(`/composer/${artist.id}`)}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
}}
>
<div className="composer-card-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="composer-card-meta">
{t('composers.involvedIn', { count: artist.albumCount })}
</div>
)}
</div>
)}
/>
)}
{!loading && viewMode === 'list' && (
perfFlags.disableMainstageVirtualLists ? (
<>
{letters.map(letter => (
<div key={letter} style={{ marginBottom: '1.5rem' }}>
<h3 className="letter-heading">{letter}</h3>
<div className="artist-list">
{groups[letter].map(artist => (
<button
key={artist.id}
className="artist-row"
onClick={() => navigate(`/composer/${artist.id}`)}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
}}
id={`composer-${artist.id}`}
>
<ComposerRowAvatar artist={artist} />
<div style={{ textAlign: 'left' }}>
<div className="artist-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
</button>
))}
</div>
{!loading && viewMode === 'grid' && (
<VirtualCardGrid
items={visible}
itemKey={(a, _i) => a.id}
rowVariant="composer"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={visible.length}
wrapClassName="composer-grid-wrap"
gridGap="var(--space-2)"
scrollRootId={COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID}
renderItem={artist => (
<div
className="composer-card"
onClick={() => navigate(`/composer/${artist.id}`)}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
}}
>
<div className="composer-card-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="composer-card-meta">
{t('composers.involvedIn', { count: artist.albumCount })}
</div>
)}
</div>
))}
</>
) : (
<div ref={composerListWrapRef} style={{ position: 'relative', width: '100%' }}>
<div
style={{
height: composerListFlatRows.length === 0 ? 0 : composerListVirtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{composerListVirtualizer.getVirtualItems().map(vi => {
const row = composerListFlatRows[vi.index];
if (!row) return null;
if (row.kind === 'letter') {
)}
/>
)}
{!loading && viewMode === 'list' && (
perfFlags.disableMainstageVirtualLists ? (
<>
{letters.map(letter => (
<div key={letter} style={{ marginBottom: '1.5rem' }}>
<h3 className="letter-heading">{letter}</h3>
<div className="artist-list">
{groups[letter].map(artist => (
<button
key={artist.id}
className="artist-row"
onClick={() => navigate(`/composer/${artist.id}`)}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
}}
id={`composer-${artist.id}`}
>
<ComposerRowAvatar artist={artist} />
<div style={{ textAlign: 'left' }}>
<div className="artist-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
</button>
))}
</div>
</div>
))}
</>
) : (
<div ref={composerListWrapRef} style={{ position: 'relative', width: '100%' }}>
<div
style={{
height: composerListFlatRows.length === 0 ? 0 : composerListVirtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{composerListVirtualizer.getVirtualItems().map(vi => {
const row = composerListFlatRows[vi.index];
if (!row) return null;
if (row.kind === 'letter') {
return (
<div
key={vi.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start - composerListScrollMargin}px)`,
}}
>
<h3 className="letter-heading">{row.letter}</h3>
</div>
);
}
const artist = row.artist;
return (
<div
key={vi.key}
@@ -370,62 +437,47 @@ export default function Composers() {
left: 0,
width: '100%',
transform: `translateY(${vi.start - composerListScrollMargin}px)`,
paddingBottom: row.isLastInLetter ? '1.5rem' : undefined,
}}
>
<h3 className="letter-heading">{row.letter}</h3>
<button
type="button"
className="artist-row"
onClick={() => navigate(`/composer/${artist.id}`)}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
}}
id={`composer-${artist.id}`}
>
<ComposerRowAvatar artist={artist} />
<div style={{ textAlign: 'left' }}>
<div className="artist-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
</button>
</div>
);
}
const artist = row.artist;
return (
<div
key={vi.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start - composerListScrollMargin}px)`,
paddingBottom: row.isLastInLetter ? '1.5rem' : undefined,
}}
>
<button
type="button"
className="artist-row"
onClick={() => navigate(`/composer/${artist.id}`)}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
}}
id={`composer-${artist.id}`}
>
<ComposerRowAvatar artist={artist} />
<div style={{ textAlign: 'left' }}>
<div className="artist-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
</button>
</div>
);
})}
})}
</div>
</div>
)
)}
{!loading && hasMore && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loadingMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)
)}
)}
{!loading && hasMore && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loadingMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
{!loading && filtered.length === 0 && (
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
{t('composers.notFound')}
</div>
)}
{!loading && filtered.length === 0 && (
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
{t('composers.notFound')}
</div>
)}
</OverlayScrollArea>
</div>
);
}
+117 -78
View File
@@ -2,7 +2,7 @@ import { buildDownloadUrl } from '../api/subsonicStreamUrl';
import { getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import AlbumCard from '../components/AlbumCard';
import { ndListLosslessAlbumsPage } from '../api/navidromeBrowse';
import { useTranslation } from 'react-i18next';
@@ -12,12 +12,15 @@ 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 { VirtualCardGrid } from '../components/VirtualCardGrid';
import OverlayScrollArea from '../components/OverlayScrollArea';
import { LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
/** Per-loadMore budget — tuned for snappy initial paint over completeness.
* 100 songs ≈ 500 KB response (Navidrome's /api/song carries lyrics/tags/
@@ -68,6 +71,18 @@ export default function LosslessAlbums() {
* reference. */
const inFlight = useRef(false);
const observerTarget = useRef<HTMLDivElement>(null);
const scrollBodyRef = useRef<HTMLDivElement | null>(null);
const [scrollBodyEl, setScrollBodyEl] = useState<HTMLDivElement | null>(null);
const bindLosslessScrollBody = useCallback((el: HTMLDivElement | null) => {
scrollBodyRef.current = el;
setScrollBodyEl(el);
}, []);
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
unsupported,
selectionMode,
activeServerId,
]);
const loadMore = useCallback(async () => {
if (inFlight.current) return;
@@ -140,13 +155,17 @@ export default function LosslessAlbums() {
if (!hasMore) return;
const node = observerTarget.current;
if (!node) return;
const root = scrollBodyRef.current;
const obs = new IntersectionObserver(
entries => { if (entries[0].isIntersecting) loadMore(); },
{ rootMargin: '200px' },
{
root: root instanceof HTMLElement ? root : null,
rootMargin: '200px',
},
);
obs.observe(node);
return () => obs.disconnect();
}, [hasMore, loadMore, loading, albums.length]);
}, [hasMore, loadMore, loading, albums.length, scrollBodyEl]);
const handleEnqueueSelected = async () => {
if (selectedAlbums.length === 0) return;
@@ -202,87 +221,107 @@ export default function LosslessAlbums() {
};
return (
<div className="content-body animate-fade-in">
<div className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}>
{!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 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) && (
<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>
</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>
) : (
<>
<VirtualCardGrid
items={albums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length}
renderItem={a => (
<AlbumCard
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
)}
/>
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
<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,
perfFlags.disableMainstageVirtualLists,
perfFlags.disableMainstageStickyHeader,
]}
>
{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>
) : (
<>
<VirtualCardGrid
items={albums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length}
scrollRootId={LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
renderItem={a => (
<AlbumCard
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
)}
/>
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
</>
)}
</OverlayScrollArea>
</div>
);
}
+111 -73
View File
@@ -3,22 +3,24 @@ import { getAlbumsByGenre } from '../api/subsonicGenres';
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { dedupeById } from '../utils/dedupeById';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
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 { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/ui/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { useRangeSelection } from '../hooks/useRangeSelection';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
import OverlayScrollArea from '../components/OverlayScrollArea';
import { NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
const PAGE_SIZE = 30;
@@ -46,15 +48,26 @@ export default function NewReleases() {
const [hasMore, setHasMore] = useState(true);
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
const observerTarget = useRef<HTMLDivElement>(null);
const scrollBodyRef = useRef<HTMLDivElement | null>(null);
const [scrollBodyEl, setScrollBodyEl] = useState<HTMLDivElement | null>(null);
const bindNewReleasesScrollBody = useCallback((el: HTMLDivElement | null) => {
scrollBodyRef.current = el;
setScrollBodyEl(el);
}, []);
const [selectionMode, setSelectionMode] = useState(false);
const filtered = selectedGenres.length > 0;
const [selectionMode, setSelectionMode] = useState(false);
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
filtered,
selectionMode,
selectedGenres,
]);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums);
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
const clearSelection = () => { setSelectionMode(false); resetSelection(); };
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
@@ -130,83 +143,108 @@ export default function NewReleases() {
}, [loading, hasMore, page, load, filtered]);
useEffect(() => {
const node = observerTarget.current;
if (!node) return;
const root = scrollBodyRef.current;
const observer = new IntersectionObserver(
entries => { if (entries[0].isIntersecting) loadMore(); },
{ rootMargin: '200px' }
entries => { if (entries[0]?.isIntersecting) loadMore(); },
{
root: root instanceof HTMLElement ? root : null,
rootMargin: '200px',
},
);
if (observerTarget.current) observer.observe(observerTarget.current);
observer.observe(node);
return () => observer.disconnect();
}, [loadMore]);
}, [loadMore, scrollBodyEl]);
return (
<div className="content-body animate-fade-in">
<div className="page-sticky-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size })
: t('sidebar.newReleases')}
</h1>
<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={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>
</>
) : (
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
)}
<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 className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}>
<div className="mainstage-inpage-toolbar">
<div className="page-sticky-header mainstage-inpage-toolbar-row">
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size })
: t('sidebar.newReleases')}
</h1>
<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={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>
</>
) : (
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
)}
<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>
{loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" />
</div>
) : !loading && albums.length === 0 && !filtered ? (
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{t('common.libraryEmpty')}
</div>
) : (
<>
<VirtualCardGrid
items={albums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length}
renderItem={a => (
<AlbumCard
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
<OverlayScrollArea
className="mainstage-inpage-scroll"
viewportClassName="mainstage-inpage-scroll__viewport"
viewportId={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID}
viewportRef={bindNewReleasesScrollBody}
railInset="panel"
measureDeps={[
loading,
albums.length,
filtered,
hasMore,
selectionMode,
perfFlags.disableMainstageVirtualLists,
]}
>
{loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" />
</div>
) : !loading && albums.length === 0 && !filtered ? (
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{t('common.libraryEmpty')}
</div>
) : (
<>
<VirtualCardGrid
items={albums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length}
scrollRootId={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID}
renderItem={a => (
<AlbumCard
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
)}
/>
{!filtered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
/>
{!filtered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
</>
)}
</>
)}
</OverlayScrollArea>
</div>
);
}