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
+9 -2
View File
@@ -22,7 +22,10 @@ import OrbitAccountPicker from '../components/OrbitAccountPicker';
import OrbitHelpModal from '../components/OrbitHelpModal';
import TooltipPortal from '../components/TooltipPortal';
import OverlayScrollArea from '../components/OverlayScrollArea';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import {
APP_MAIN_SCROLL_VIEWPORT_ID,
MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH,
} from '../constants/appScroll';
import ConnectionIndicator from '../components/ConnectionIndicator';
import LastfmIndicator from '../components/LastfmIndicator';
import OfflineBanner from '../components/OfflineBanner';
@@ -212,7 +215,11 @@ export function AppShell() {
<div className="content-body app-shell-route-host">
<OverlayScrollArea
className="app-shell-route-scroll"
viewportClassName="app-shell-route-scroll__viewport"
viewportClassName={
MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH[location.pathname]
? 'app-shell-route-scroll__viewport app-shell-route-scroll__viewport--inpage-split'
: 'app-shell-route-scroll__viewport'
}
viewportId={APP_MAIN_SCROLL_VIEWPORT_ID}
measureDeps={[location.pathname, isQueueVisible, queueWidth, floatingPlayerBar]}
railInset="panel"
+20 -5
View File
@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useCallback, useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
@@ -20,11 +20,14 @@ export type VirtualCardGridProps<T> = {
wrapStyle?: React.CSSProperties;
/** Defaults to `var(--space-4)`; composer grid uses `var(--space-2)`. */
gridGap?: string;
/** When set, row virtualization uses this scroll container instead of the main route viewport. */
scrollRootId?: string;
};
/**
* Album-/playlist-style card grids: at most six columns, proportional stretch,
* optional row virtualization with scroll root `#APP_MAIN_SCROLL_VIEWPORT_ID`.
* optional row virtualization with scroll root `#APP_MAIN_SCROLL_VIEWPORT_ID`
* (or `scrollRootId` when the grid lives in an in-page overlay viewport).
*/
export function VirtualCardGrid<T>({
items,
@@ -36,13 +39,25 @@ export function VirtualCardGrid<T>({
wrapClassName = 'album-grid-wrap',
wrapStyle,
gridGap = 'var(--space-4)',
scrollRootId,
}: VirtualCardGridProps<T>): React.JSX.Element {
const wrapRef = useRef<HTMLDivElement>(null);
const { gridCols, rowHeightEst } = useCardGridMetrics(wrapRef, true, rowVariant, layoutSignal);
const cols = Math.max(1, gridCols);
const virtualRowCount = Math.max(0, Math.ceil(items.length / cols));
const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
const overscan = Math.max(2, Math.ceil(mainScrollViewportHeight / Math.max(1, rowHeightEst)));
const scrollMetricsElementId = scrollRootId ?? APP_MAIN_SCROLL_VIEWPORT_ID;
const scrollViewportClientHeight = useElementClientHeightById(scrollMetricsElementId);
const overscan = Math.max(2, Math.ceil(scrollViewportClientHeight / Math.max(1, rowHeightEst)));
const getScrollElement = useCallback((): HTMLElement | null => {
if (scrollRootId) {
return (
document.getElementById(scrollRootId) as HTMLElement | null
?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null)
);
}
return document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null;
}, [scrollRootId]);
const scrollMargin = useVirtualizerScrollMargin(
wrapRef,
@@ -55,7 +70,7 @@ export function VirtualCardGrid<T>({
const virtualizer = useVirtualizer({
count: disableVirtualization ? 0 : virtualRowCount,
getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
getScrollElement,
estimateSize: () => rowHeightEst,
overscan,
scrollMargin,
+1 -1
View File
@@ -57,7 +57,7 @@ function ArtistGridTile({ artist, ...rest }: TileProps) {
</div>
)}
<ArtistCardAvatar artist={artist} showImages={rest.showArtistImages} />
<div style={{ textAlign: 'center' }}>
<div className="artist-card-info artist-card-info--center">
<div className="artist-card-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-card-meta">{rest.t('artists.albumCount', { count: artist.albumCount })}</div>
+31 -1
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { invoke } from '@tauri-apps/api/core';
@@ -7,7 +8,7 @@ import { AppWindow, ChevronDown, Download, ExternalLink, Globe, HardDrive, Info,
import { version as appVersion } from '../../../package.json';
import i18n from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import type { ClockFormat, LoggingMode } from '../../store/authStoreTypes';
import type { ClockFormat, LinuxWaylandTextRenderProfile, LoggingMode } from '../../store/authStoreTypes';
import { IS_LINUX } from '../../utils/platform';
import { showToast } from '../../utils/ui/toast';
import { AboutPsysonicBrandHeader } from '../AboutPsysonicLol';
@@ -21,6 +22,14 @@ export function SystemTab() {
const { t } = useTranslation();
const navigate = useNavigate();
const auth = useAuthStore();
const [waylandTextRenderAvailable, setWaylandTextRenderAvailable] = useState(false);
useEffect(() => {
if (!IS_LINUX) return;
invoke<boolean>('linux_wayland_text_render_settings_available')
.then(setWaylandTextRenderAvailable)
.catch(() => {});
}, []);
const exportRuntimeLogs = async () => {
const suggestedName = `psysonic-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.log`;
@@ -110,6 +119,27 @@ export function SystemTab() {
<span className="toggle-track" />
</label>
</div>
{waylandTextRenderAvailable && (
<>
<div className="settings-section-divider" />
<div className="form-group" style={{ maxWidth: '420px' }}>
<div style={{ fontWeight: 500 }}>{t('settings.linuxWaylandTextRender')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{t('settings.linuxWaylandTextRenderDesc')}
</div>
<CustomSelect
value={auth.linuxWaylandTextRenderProfile}
onChange={v => auth.setLinuxWaylandTextRenderProfile(v as LinuxWaylandTextRenderProfile)}
options={[
{ value: 'balanced', label: t('settings.linuxWaylandTextRenderBalanced') },
{ value: 'sharp', label: t('settings.linuxWaylandTextRenderSharp') },
{ value: 'gpu', label: t('settings.linuxWaylandTextRenderGpu') },
{ value: 'minimal', label: t('settings.linuxWaylandTextRenderMinimal') },
]}
/>
</div>
</>
)}
</>
)}
<div className="settings-section-divider" />
+1
View File
@@ -118,6 +118,7 @@ const CONTRIBUTOR_ENTRIES = [
'HTTP stream buffering — seekbar/timer at zero and cover overlay until playback arms (PR #737)',
'M4A playback: fix AtomIterator overread in patched isomp4 demuxer — probe gate for ranged tail prefetch, zero-hole fallback detection (PR #757)',
'Multi-server: Lucky Mix and Now Playing keep browsed server; queue metadata via apiForServer (PR #768)',
'Linux: session GDK/WebKit mitigations, in-page library scroll, Wayland text presets (PR #731)',
],
},
{
+16
View File
@@ -1,2 +1,18 @@
/** Main scroll element wrapping `<Routes />` in App (overlay scrollbar). */
export const APP_MAIN_SCROLL_VIEWPORT_ID = 'app-main-scroll-viewport';
/** In-page list/grid viewports when the main route scroll is locked (see AppShell). */
export const ARTISTS_INPAGE_SCROLL_VIEWPORT_ID = 'artists-inpage-scroll-viewport';
export const ALBUMS_INPAGE_SCROLL_VIEWPORT_ID = 'albums-inpage-scroll-viewport';
export const NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID = 'new-releases-inpage-scroll-viewport';
export const LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID = 'lossless-albums-inpage-scroll-viewport';
export const COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID = 'composers-inpage-scroll-viewport';
/** Route pathname → in-page overlay viewport id (must match `viewportId` on each page). */
export const MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH: Readonly<Record<string, string>> = {
'/artists': ARTISTS_INPAGE_SCROLL_VIEWPORT_ID,
'/albums': ALBUMS_INPAGE_SCROLL_VIEWPORT_ID,
'/new-releases': NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID,
'/lossless-albums': LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID,
'/composers': COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID,
};
+5 -2
View File
@@ -4,6 +4,8 @@ import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
interface UseArtistsInfiniteScrollArgs {
pageSize: number;
resetDeps: ReadonlyArray<unknown>;
/** IntersectionObserver root (e.g. Artists in-page overlay viewport). */
getScrollRoot?: () => HTMLElement | null;
}
interface UseArtistsInfiniteScrollResult {
@@ -32,6 +34,7 @@ interface UseArtistsInfiniteScrollResult {
export function useArtistsInfiniteScroll({
pageSize,
resetDeps,
getScrollRoot,
}: UseArtistsInfiniteScrollArgs): UseArtistsInfiniteScrollResult {
const [visibleCount, setVisibleCount] = useState(pageSize);
const [loadingMore, setLoadingMore] = useState(false);
@@ -58,7 +61,7 @@ export function useArtistsInfiniteScroll({
observerInst.current = null;
if (!node) return;
const rootEl = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
const rootEl = getScrollRoot?.() ?? document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
const observer = new IntersectionObserver(
entries => {
if (entries[0]?.isIntersecting) loadMoreRef.current();
@@ -70,7 +73,7 @@ export function useArtistsInfiniteScroll({
);
observer.observe(node);
observerInst.current = observer;
}, []);
}, [getScrollRoot]);
useEffect(() => () => {
observerInst.current?.disconnect();
+16 -8
View File
@@ -1,17 +1,20 @@
import { useEffect, useState } from 'react';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import {
APP_MAIN_SCROLL_VIEWPORT_ID,
MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH,
} from '../constants/appScroll';
const SCROLL_IDLE_MS = 180;
/**
* `true` while the main route viewport or the Now Playing viewport is
* actively scrolling, falling back to `false` after `SCROLL_IDLE_MS` of
* silence. Used to fade out the queue handle (and similar floating
* controls) while the user is scrolling, so they don't sit on top of the
* overlay scrollbar thumb.
* `true` while a tracked viewport is actively scrolling, then `false` after
* `SCROLL_IDLE_MS` of silence. Used to fade out the queue handle (and similar
* floating controls) while the user is scrolling, so they don't sit on top of
* the overlay scrollbar thumb.
*
* Re-binds on `pathname` change because Now Playing's viewport mounts
* lazily and isn't in the DOM on every route.
* Tracks `#app-main-scroll-viewport`, and on browse routes with a locked main
* scroll also the matching in-page overlay viewport. Re-binds on `pathname`
* because Now Playing's viewport mounts lazily.
*/
export function useMainScrollingIndicator(pathname: string): boolean {
const [isMainScrolling, setIsMainScrolling] = useState(false);
@@ -22,6 +25,11 @@ export function useMainScrollingIndicator(pathname: string): boolean {
if (appViewport) viewports.add(appViewport);
const nowPlayingViewport = document.querySelector<HTMLElement>('.np-main__viewport');
if (nowPlayingViewport) viewports.add(nowPlayingViewport);
const inpageId = MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH[pathname];
if (inpageId) {
const inpageVp = document.getElementById(inpageId);
if (inpageVp) viewports.add(inpageVp);
}
if (viewports.size === 0) return;
let scrollHideTimer: number | null = null;
@@ -0,0 +1,39 @@
import { useEffect, useState } from 'react';
const TIGHT_AFTER_PX = 10;
const LOOSE_BELOW_PX = 2;
/**
* Compact the browse toolbar when the in-page overlay viewport scrolls down,
* same thresholds as Artists (`> 10` tight, `< 2` loose).
*/
export function useMainstageInpageHeaderTight(
scrollBodyEl: HTMLElement | null,
resetDeps: ReadonlyArray<unknown>,
): boolean {
const [tight, setTight] = useState(false);
useEffect(() => {
if (!scrollBodyEl) return;
const el = scrollBodyEl;
const onScroll = () => {
const y = el.scrollTop;
setTight(prev => {
if (y > TIGHT_AFTER_PX) return true;
if (y < LOOSE_BELOW_PX) return false;
return prev;
});
};
el.addEventListener('scroll', onScroll, { passive: true });
onScroll();
return () => el.removeEventListener('scroll', onScroll);
}, [scrollBodyEl]);
useEffect(() => {
setTight(false);
// Spread values so deps track filter keys, not a new array identity each render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...resetDeps]);
return tight;
}
+63
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import type { LinuxWaylandTextRenderProfile } from '../store/authStoreTypes';
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
/**
@@ -12,8 +13,10 @@ import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
*/
export function usePlatformShellSetup(): { isTilingWm: boolean } {
const [isTilingWm, setIsTilingWm] = useState(false);
const [waylandTextUi, setWaylandTextUi] = useState(false);
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
const linuxWebkitKineticScroll = useAuthStore(s => s.linuxWebkitKineticScroll);
const linuxWaylandTextRenderProfile = useAuthStore(s => s.linuxWaylandTextRenderProfile);
const loggingMode = useAuthStore(s => s.loggingMode);
useEffect(() => {
@@ -28,11 +31,54 @@ export function usePlatformShellSetup(): { isTilingWm: boolean } {
}).catch(() => {});
}, []);
useEffect(() => {
if (!IS_LINUX) return;
invoke<boolean>('linux_wayland_text_render_settings_available')
.then(av => {
setWaylandTextUi(av);
if (av) {
document.documentElement.setAttribute('data-linux-session', 'wayland');
} else {
document.documentElement.removeAttribute('data-linux-session');
document.documentElement.removeAttribute('data-wayland-text-profile');
}
})
.catch(() => {});
}, []);
useEffect(() => {
const platform = IS_LINUX ? 'linux' : IS_MACOS ? 'macos' : IS_WINDOWS ? 'windows' : 'unknown';
document.documentElement.setAttribute('data-platform', platform);
}, []);
// Wayland text profile: CSS on <html> updates live; Rust persists for next launch / new mini webview
// (WebKitGTK can hang when hardware-acceleration-policy is toggled repeatedly at runtime).
useEffect(() => {
if (!IS_LINUX || !waylandTextUi) {
document.documentElement.removeAttribute('data-wayland-text-profile');
return;
}
let cancelHydration: (() => void) | undefined;
const apply = (profile: LinuxWaylandTextRenderProfile) => {
document.documentElement.setAttribute('data-wayland-text-profile', profile);
invoke('set_linux_wayland_text_render_profile', { profile }).catch(() => {});
};
apply(linuxWaylandTextRenderProfile);
if (!useAuthStore.persist.hasHydrated()) {
cancelHydration = useAuthStore.persist.onFinishHydration(() => {
apply(useAuthStore.getState().linuxWaylandTextRenderProfile);
});
}
return () => {
cancelHydration?.();
};
}, [IS_LINUX, waylandTextUi, linuxWaylandTextRenderProfile]);
// Sync custom titlebar preference with native decorations on Linux.
// On tiling WMs decorations are always off (no native title bar to replace).
useEffect(() => {
@@ -46,6 +92,23 @@ export function usePlatformShellSetup(): { isTilingWm: boolean } {
invoke('set_linux_webkit_smooth_scrolling', { enabled: linuxWebkitKineticScroll }).catch(() => {});
}, [linuxWebkitKineticScroll]);
// Persist rehydrates after first paint — default store has kinetic scroll ON until localStorage merges.
// Re-apply OS WebKit prefs after hydrate (same pattern as useMiniWindowSetup) so OFF stays OFF.
useEffect(() => {
if (!IS_LINUX) return;
const applySmoothFromStore = () => {
invoke('set_linux_webkit_smooth_scrolling', {
enabled: useAuthStore.getState().linuxWebkitKineticScroll,
}).catch(() => {});
};
if (useAuthStore.persist.hasHydrated()) {
applySmoothFromStore();
}
return useAuthStore.persist.onFinishHydration(() => {
applySmoothFromStore();
});
}, []);
useEffect(() => {
invoke('set_logging_mode', { mode: loggingMode }).catch(() => {});
}, [loggingMode]);
+20
View File
@@ -37,3 +37,23 @@ export function useRefElementClientHeight(
}, [ref, fallback]);
return h;
}
/** ResizeObserver on a concrete element (e.g. callback-ref state for in-page scrollers). */
export function useElementClientHeightForElement(
element: HTMLElement | null,
fallback = 600,
): number {
const [h, setH] = useState(fallback);
useLayoutEffect(() => {
if (!element) {
setH(fallback);
return;
}
const update = () => setH(element.clientHeight);
const ro = new ResizeObserver(update);
ro.observe(element);
update();
return () => ro.disconnect();
}, [element, fallback]);
return h;
}
+1 -1
View File
@@ -111,5 +111,5 @@ export const help = {
q44: 'Cover und Künstlerbilder laden langsam.',
a44: 'Bilder werden beim ersten Aufruf vom Server geholt und dann 30 Tage lokal gecacht. Bei langsamen Server-Festplatten kann der erste Seitenaufruf einen Moment dauern; danach sind sie sofort da. Der Hot Cache hilft auch bei Tracks, der Bild-Cache ist davon unabhängig.',
q45: 'Linux-Probleme — schwarzer Bildschirm oder kein Ton?',
a45: 'Schwarzer Bildschirm ist meist ein GPU- / EGL-Treiberproblem in WebKitGTK — mit GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 starten (die AUR- / .deb- / .rpm-Installer setzen das automatisch). Für Audio sicherstellen, dass PipeWire oder PulseAudio läuft. Tonaussetzer nach Sleep / Wake werden mittlerweile vom Post-Sleep-Recovery-Hook automatisch behandelt.',
a45: 'Ein schwarzer Bildschirm unter Linux betrifft meist Grafik oder die WebView-Anzeige. Versuchen Sie eine X11-Sitzung oder setzen Sie vor dem Start GDK_BACKEND=x11 und EGL_PLATFORM=x11 (unter Wayland). Für Ton: PipeWire oder PulseAudio. Verschwindet der Ton nach Standby, beenden Sie Psysonic vollständig und starten Sie neu.',
};
+7
View File
@@ -185,6 +185,13 @@ export const settings = {
useCustomTitlebarDesc: 'Ersetzt die System-Titelleiste durch eine eingebaute, die zum App-Theme passt. Deaktivieren, um die native GNOME/GTK-Titelleiste zu verwenden.',
linuxWebkitSmoothScroll: 'Sanftes Mausrad (Linux)',
linuxWebkitSmoothScrollDesc: 'An: mit Nachlauf. Aus: zeilenweise wie in GTK-Apps.',
linuxWaylandTextRender: 'Wayland-Textdarstellung (Linux)',
linuxWaylandTextRenderDesc:
'Kantenglättung in der Oberfläche wirkt sofort. Die WebKit-Beschleunigungsrichtlinie (scharf/GPU) wird gespeichert und beim nächsten App-Start angewendet — ein Live-Umschalten kann WebKitGTK einfrieren.',
linuxWaylandTextRenderBalanced: 'Ausgewogen',
linuxWaylandTextRenderSharp: 'Scharf (CPU-freundlich)',
linuxWaylandTextRenderGpu: 'GPU zuerst',
linuxWaylandTextRenderMinimal: 'Minimal (Standard-CSS-Glättung)',
discordCoverSource: 'Cover-Quelle',
discordCoverSourceDesc: 'Woher das Album-Cover für dein Discord-Profil geladen wird.',
discordCoverNone: 'Keine (nur App-Symbol)',
+1 -1
View File
@@ -111,5 +111,5 @@ export const help = {
q44: 'Cover art and artist images load slowly.',
a44: 'Images are fetched from your server on first view and then cached locally for 30 days. If your server\'s storage is slow, the first visit to a page can take a moment; subsequent visits are instant. The Hot Cache also helps for tracks but image cache is independent.',
q45: 'Linux issues — black screen or no audio?',
a45: 'Black screen is usually a GPU / EGL driver issue in WebKitGTK — launch with GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 (the AUR / .deb / .rpm installers set these automatically). For audio, make sure PipeWire or PulseAudio is running. If audio drops out after sleep / wake, this is now handled automatically by the post-sleep recovery hook.',
a45: 'A black screen on Linux is usually a graphics or WebView display issue. Try an X11 session, or set GDK_BACKEND=x11 and EGL_PLATFORM=x11 before launching if you use Wayland. For audio, make sure PipeWire or PulseAudio is running. If sound stops after sleep or wake, quit Psysonic completely and open it again.',
};
+7
View File
@@ -188,6 +188,13 @@ export const settings = {
useCustomTitlebarDesc: 'Replace the system title bar with a built-in one that matches the app theme. Disable to use the native GNOME/GTK title bar.',
linuxWebkitSmoothScroll: 'Smooth wheel (Linux)',
linuxWebkitSmoothScrollDesc: 'On: inertial scroll. Off: line-by-line, GTK-style.',
linuxWaylandTextRender: 'Wayland text rendering (Linux)',
linuxWaylandTextRenderDesc:
'Font smoothing in the UI updates immediately. WebKit hardware acceleration (sharp / GPU presets) is saved and applied on the next app start — changing it live can freeze WebKitGTK on some setups.',
linuxWaylandTextRenderBalanced: 'Balanced',
linuxWaylandTextRenderSharp: 'Sharp (CPU-friendly)',
linuxWaylandTextRenderGpu: 'GPU-first',
linuxWaylandTextRenderMinimal: 'Minimal (default CSS smoothing)',
discordCoverSource: 'Cover art source',
discordCoverSourceDesc: 'Where to fetch album artwork shown on your Discord profile.',
discordCoverNone: 'None (app icon only)',
+1 -1
View File
@@ -101,5 +101,5 @@ export const help = {
q44: 'Las portadas e imágenes de artista cargan lentamente.',
a44: 'Las imágenes se obtienen del servidor en la primera vista y luego se almacenan localmente durante 30 días. Si el almacenamiento de tu servidor es lento, la primera visita a una página puede tardar un momento; las visitas posteriores son instantáneas. Hot Cache también ayuda con pistas pero el caché de imágenes es independiente.',
q45: 'Problemas en Linux — ¿pantalla negra o sin sonido?',
a45: 'La pantalla negra es generalmente un problema de driver GPU / EGL en WebKitGTK — lanza con GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 (los instaladores AUR / .deb / .rpm los configuran automáticamente). Para audio, asegúrate de que PipeWire o PulseAudio esté ejecutándose. Los cortes de audio tras suspensión / despertar ahora son manejados automáticamente por el hook de recuperación post-sleep.',
a45: 'La pantalla negra en Linux suele ser un problema de gráficos o de la ventana WebView. Prueba una sesión X11 o, antes de abrir la app, exporta GDK_BACKEND=x11 y EGL_PLATFORM=x11 si usas Wayland. Audio: PipeWire o PulseAudio. Si pierdes el sonido tras suspender, cierra por completo Psysonic y vuelve a abrirlo.',
};
+7
View File
@@ -185,6 +185,13 @@ export const settings = {
useCustomTitlebarDesc: 'Reemplaza la barra de título del sistema con una integrada que coincide con el tema de la app. Desactiva para usar la barra nativa de GNOME/GTK.',
linuxWebkitSmoothScroll: 'Rueda suave (Linux)',
linuxWebkitSmoothScrollDesc: 'Activado: inercia. Desactivado: pasos por línea (estilo GTK).',
linuxWaylandTextRender: 'Renderizado de texto Wayland (Linux)',
linuxWaylandTextRenderDesc:
'El suavizado en la interfaz se aplica al instante. La aceleración de hardware de WebKit (nítido/GPU) se guarda y aplica al reiniciar la app; cambiarla en vivo puede congelar WebKitGTK.',
linuxWaylandTextRenderBalanced: 'Equilibrado',
linuxWaylandTextRenderSharp: 'Nítido (más CPU)',
linuxWaylandTextRenderGpu: 'Prioridad GPU',
linuxWaylandTextRenderMinimal: 'Mínimo (suavizado CSS por defecto)',
discordCoverSource: 'Fuente de portada',
discordCoverSourceDesc: 'De dónde obtener la portada del álbum para tu perfil de Discord.',
discordCoverNone: 'Ninguna (solo icono de la app)',
+1 -1
View File
@@ -101,5 +101,5 @@ export const help = {
q44: 'Les pochettes et images d\'artiste se chargent lentement.',
a44: 'Les images sont récupérées du serveur à la première vue puis mises en cache localement pour 30 jours. Si le stockage du serveur est lent, la première visite d\'une page peut prendre un instant ; les visites suivantes sont instantanées. Le Hot Cache aide aussi pour les pistes mais le cache d\'images est indépendant.',
q45: 'Problèmes Linux — écran noir ou pas de son ?',
a45: 'L\'écran noir est généralement un souci de pilote GPU / EGL dans WebKitGTK — lancez avec GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 (les installeurs AUR / .deb / .rpm le font automatiquement). Pour le son, vérifiez que PipeWire ou PulseAudio tourne. Les coupures audio après mise en veille sont désormais gérées automatiquement par le hook de récupération post-sleep.',
a45: 'L\'écran noir sous Linux indique souvent un souci d\'affichage WebView ou graphique. Essayez une session X11, ou définissez GDK_BACKEND=x11 et EGL_PLATFORM=x11 avant le lancement sous Wayland. Audio : PipeWire ou PulseAudio. Si le son disparaît après une mise en veille, quittez entièrement Psysonic puis rouvrez-le.',
};
+7
View File
@@ -181,6 +181,13 @@ export const settings = {
preloadMiniPlayerDesc: 'Construit la fenêtre du mini-lecteur en arrière-plan au démarrage de l\'application afin qu\'elle affiche son contenu instantanément à la première ouverture. Utilise un peu plus de mémoire.',
linuxWebkitSmoothScroll: 'Molette fluide (Linux)',
linuxWebkitSmoothScrollDesc: 'Activé : inertie. Désactivé : pas à la ligne, style GTK.',
linuxWaylandTextRender: 'Rendu du texte Wayland (Linux)',
linuxWaylandTextRenderDesc:
'Le lissage dans linterface est immédiat. Laccélération matérielle WebKit (net / GPU) est enregistrée et appliquée au prochain lancement — la modifier en direct peut figer WebKitGTK.',
linuxWaylandTextRenderBalanced: 'Équilibré',
linuxWaylandTextRenderSharp: 'Net (favorise le CPU)',
linuxWaylandTextRenderGpu: 'GPU prioritaire',
linuxWaylandTextRenderMinimal: 'Minimal (lissage CSS par défaut)',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Affiche le titre en cours de lecture sur votre profil Discord. Discord doit être ouvert.',
discordCoverSource: 'Source de pochette',
+1 -1
View File
@@ -101,5 +101,5 @@ export const help = {
q44: 'Cover-art og artistbilder lastes sakte.',
a44: 'Bilder hentes fra serveren ved første visning og caches deretter lokalt i 30 dager. Hvis serverens lagring er treg, kan første besøk på en side ta et øyeblikk; påfølgende besøk er umiddelbare. Hot Cache hjelper også for spor, men bildecachen er uavhengig.',
q45: 'Linux-problemer — svart skjerm eller ingen lyd?',
a45: 'Svart skjerm er vanligvis et GPU- / EGL-driverproblem i WebKitGTK — start med GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 (AUR / .deb / .rpm-installerne setter disse automatisk). For lyd, sørg for at PipeWire eller PulseAudio kjører. Lydutfall etter dvale / oppvåkning håndteres nå automatisk av post-sleep recovery hook.',
a45: 'Svart skjerm på Linux tyder vanligvis på grafikk eller WebView-visning. Prøv X11-økt, eller sett GDK_BACKEND=x11 og EGL_PLATFORM=x11 før start ved Wayland. Lyd: PipeWire eller PulseAudio. Forsvinner lyd etter dvale: lukk Psysonic helt og åpne igjen.',
};
+7
View File
@@ -180,6 +180,13 @@ export const settings = {
preloadMiniPlayerDesc: 'Bygger miniavspiller-vinduet i bakgrunnen ved appstart slik at det viser innhold umiddelbart ved første åpning. Bruker litt mer minne.',
linuxWebkitSmoothScroll: 'Mykt musehjul (Linux)',
linuxWebkitSmoothScrollDesc: 'På: treg rull med etterslep. Av: trinnvis som i GTK.',
linuxWaylandTextRender: 'Wayland-tekstgjengivelse (Linux)',
linuxWaylandTextRenderDesc:
'Utjevning i grensesnittet skjer med én gang. WebKit-maskinvareakselerasjon (skarp/GPU) lagres og brukes ved neste appstart — live bytte kan fryse WebKitGTK.',
linuxWaylandTextRenderBalanced: 'Balansert',
linuxWaylandTextRenderSharp: 'Skarpt (CPU-vennlig)',
linuxWaylandTextRenderGpu: 'GPU først',
linuxWaylandTextRenderMinimal: 'Minimum (standard CSS-utjevning)',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Vis sporet som spilles i din Discord-profil. Krever at Discord kjører.',
discordCoverSource: 'Coverkilde',
+1 -1
View File
@@ -101,5 +101,5 @@ export const help = {
q44: 'Hoezen en artiestafbeeldingen laden traag.',
a44: 'Afbeeldingen worden bij de eerste weergave van uw server gehaald en vervolgens 30 dagen lokaal gecached. Als de opslag van uw server traag is, kan het eerste bezoek aan een pagina even duren; volgende bezoeken zijn onmiddellijk. De Hot Cache helpt ook voor nummers, maar de afbeeldingscache is onafhankelijk.',
q45: 'Linux-problemen — zwart scherm of geen geluid?',
a45: 'Zwart scherm is meestal een GPU / EGL-stuurprogrammakwestie in WebKitGTK — start met GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 (de AUR / .deb / .rpm-installers stellen deze automatisch in). Voor audio, zorg ervoor dat PipeWire of PulseAudio draait. Audio-uitval na slaap / wakker worden wordt nu automatisch afgehandeld door de post-sleep recovery hook.',
a45: 'Een zwart scherm op Linux wijst meestal op een grafisch of WebView-weergaveprobleem. Probeer een X11-sessie of stel vóór starten GDK_BACKEND=x11 en EGL_PLATFORM=x11 in bij Wayland. Audio: PipeWire of PulseAudio. Verdwijnt geluid na slaapstand: sluit Psysonic volledig af en start opnieuw.',
};
+7
View File
@@ -181,6 +181,13 @@ export const settings = {
preloadMiniPlayerDesc: 'Bouwt het venster van de mini-speler op de achtergrond bij het opstarten van de app, zodat het bij de eerste opening direct inhoud toont. Gebruikt iets meer geheugen.',
linuxWebkitSmoothScroll: 'Vloeiend muiswiel (Linux)',
linuxWebkitSmoothScrollDesc: 'Aan: traag naloop. Uit: regel voor regel, GTK-stijl.',
linuxWaylandTextRender: 'Wayland-tekstweergave (Linux)',
linuxWaylandTextRenderDesc:
'Vloeiendheid in de UI werkt meteen. WebKit-hardwareversnelling (scherp/GPU) wordt bewaard en toegepast bij de volgende app-start — live schakelen kan WebKitGTK laten vastlopen.',
linuxWaylandTextRenderBalanced: 'Gebalanceerd',
linuxWaylandTextRenderSharp: 'Scherp (CPU-vriendelijk)',
linuxWaylandTextRenderGpu: 'GPU eerst',
linuxWaylandTextRenderMinimal: 'Minimaal (standaard CSS-verzachting)',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Toont het huidige nummer op je Discord-profiel. Discord moet daarvoor geopend zijn.',
discordCoverSource: 'Hoesbron',
+1 -1
View File
@@ -111,5 +111,5 @@ export const help = {
q44: 'Arta copertei și imaginile artiștilor se încarcă încet.',
a44: 'Imaginile sunt preluate de pe serverul tău la prima vedere și apoi stocate în cache pentru 30 de zile. Dacă stocarea serverului tău este înceată, prima vizită către o pagină poate dura un moment; vizitele consecutive sunt instantanee. Hot Cache de asemenea ajută pentru piese dar nu pentru cache-ul imaginii.',
q45: 'Probleme pe Linux - ecran negru sau niciun audio?',
a45: 'Aceasta este de obicei o problemă de driver GPU/EGL în WebKitGTK. Pornește cu GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. Pachetul AUR și installer-ele oficiale .deb/.rpm fac asta automat.',
a45: 'Ecranul negru pe Linux este de obicei o problemă de grafică sau de afișare WebView. Încearcă o sesiune X11 sau setează înainte de lansare GDK_BACKEND=x11 și EGL_PLATFORM=x11 dacă folosești Wayland. Pentru audio: PipeWire sau PulseAudio. Dacă sunetul dispare după somn, închide complet Psysonic și redeschide-l.',
};
+7
View File
@@ -188,6 +188,13 @@ export const settings = {
useCustomTitlebarDesc: 'Înlocuiește bara de titlu a sistemului cu una care corespunde cu tema aplicației. Dezactivează pentru a folosi bara de titlu nativ GNOME/GTK.',
linuxWebkitSmoothScroll: 'Rotiță lină (Linux)',
linuxWebkitSmoothScrollDesc: 'Pornit: scroll inert. Oprit: linie cu linie, în stil GTK.',
linuxWaylandTextRender: 'Randare text Wayland (Linux)',
linuxWaylandTextRenderDesc:
'Netezirea în interfață se aplică imediat. Accelerația hardware WebKit (ascuțit/GPU) este salvată și aplicată la următoarea pornire a aplicației — comutarea în timpul rulării poate îngheța WebKitGTK.',
linuxWaylandTextRenderBalanced: 'Echilibrat',
linuxWaylandTextRenderSharp: 'Ascuțit (prietenos cu CPU)',
linuxWaylandTextRenderGpu: 'Prioritate GPU',
linuxWaylandTextRenderMinimal: 'Minim (netezire CSS implicită)',
discordCoverSource: 'Sursa artei de copertă',
discordCoverSourceDesc: 'De unde să fie preluată arta de album afișată pe profilul tău de Discord.',
discordCoverNone: 'Niciuna (doar iconița aplicației)',
+1 -1
View File
@@ -101,5 +101,5 @@ export const help = {
q44: 'Обложки и изображения исполнителей загружаются медленно.',
a44: 'Изображения подгружаются с сервера при первом просмотре, затем кэшируются локально на 30 дней. Если хранилище сервера медленное, первый визит на страницу может занять момент; последующие визиты мгновенные. Hot Cache также помогает с треками, но кэш изображений независим.',
q45: 'Проблемы Linux — чёрный экран или нет звука?',
a45: 'Чёрный экран обычно проблема GPU / EGL драйвера в WebKitGTK — запускайте с GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 (AUR / .deb / .rpm установщики устанавливают это автоматически). Для аудио убедитесь, что PipeWire или PulseAudio запущены. Пропадание звука после спящего режима / пробуждения теперь обрабатывается автоматически post-sleep recovery hook.',
a45: 'Чёрный экран на Linux чаще всего связан с отображением окна приложения или графическим стеком. Попробуйте сеанс на X11 или перед запуском задайте GDK_BACKEND=x11 и EGL_PLATFORM=x11, если используете Wayland. Для звука нужны PipeWire или PulseAudio. Если после сна пропал звук полностью закройте Psysonic и откройте снова.',
};
+7
View File
@@ -190,6 +190,13 @@ export const settings = {
'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK и т.д.',
linuxWebkitSmoothScroll: 'Плавное колесо (Linux)',
linuxWebkitSmoothScrollDesc: 'Вкл — инерция. Выкл — по шагам, как в GTK.',
linuxWaylandTextRender: 'Рендеринг текста Wayland (Linux)',
linuxWaylandTextRenderDesc:
'Сглаживание в интерфейсе меняется сразу. Политику ускорения WebKit (чёткий / GPU) сохраняем и применяем при следующем запуске — переключение на лету на части сборок зависает в WebKitGTK.',
linuxWaylandTextRenderBalanced: 'Сбалансированный',
linuxWaylandTextRenderSharp: 'Чёткий (меньше GPU)',
linuxWaylandTextRenderGpu: 'С приоритетом GPU',
linuxWaylandTextRenderMinimal: 'Минимальный (сглаживание по умолчанию)',
discordRichPresence: 'Статус в Discord',
discordRichPresenceDesc:
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
+1 -1
View File
@@ -101,5 +101,5 @@ export const help = {
q44: '封面和艺术家图像加载缓慢。',
a44: '图像在首次查看时从服务器获取,然后本地缓存 30 天。如果您服务器的存储速度慢,第一次访问页面可能需要片刻;后续访问是即时的。Hot Cache 也对曲目有帮助,但图像缓存是独立的。',
q45: 'Linux 问题 — 黑屏或无声音?',
a45: '黑屏通常是 WebKitGTK 中的 GPU / EGL 驱动程序问题 — 使用 GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 启动(AUR / .deb / .rpm 安装程序自动设置这些)。对于音频,确保 PipeWire 或 PulseAudio 在运行睡眠/唤醒后的音频丢失现在由 post-sleep 恢复钩子自动处理。',
a45: 'Linux 黑屏多半是图形或应用 WebView 显示层面的问题。可改用 X11 会话,或在 Wayland 下启动前设置 GDK_BACKEND=x11、EGL_PLATFORM=x11。声音请确认 PipeWire 或 PulseAudio 在运行;若睡眠唤醒后无声,完全退出 Psysonic 再重新打开。',
};
+6
View File
@@ -181,6 +181,12 @@ export const settings = {
preloadMiniPlayerDesc: '在应用启动时于后台构建迷你播放器窗口,使其首次打开即可立即显示内容。会占用少量额外内存。',
linuxWebkitSmoothScroll: '滚轮平滑(Linux',
linuxWebkitSmoothScrollDesc: '开:惯性滚动。关:逐行,类似 GTK。',
linuxWaylandTextRender: 'Wayland 文本渲染(Linux',
linuxWaylandTextRenderDesc: '界面字体平滑立即生效。WebKit 硬件加速策略(锐利 / GPU)会保存并在下次启动应用时应用;运行中反复切换可能导致 WebKitGTK 卡死。',
linuxWaylandTextRenderBalanced: '平衡',
linuxWaylandTextRenderSharp: '锐利(偏 CPU',
linuxWaylandTextRenderGpu: 'GPU 优先',
linuxWaylandTextRenderMinimal: '最小(默认 CSS 平滑)',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。',
discordCoverSource: '封面来源',
+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>
);
}
+1
View File
@@ -61,6 +61,7 @@ describe('trivial pass-through setters', () => {
['setUseCustomTitlebar', 'useCustomTitlebar', true],
['setPreloadMiniPlayer', 'preloadMiniPlayer', true],
['setLinuxWebkitKineticScroll', 'linuxWebkitKineticScroll', false],
['setLinuxWaylandTextRenderProfile', 'linuxWaylandTextRenderProfile', 'gpu'],
['setNowPlayingEnabled', 'nowPlayingEnabled', true],
['setShowFullscreenLyrics', 'showFullscreenLyrics', false],
['setLyricsStaticOnly', 'lyricsStaticOnly', true],
+1
View File
@@ -73,6 +73,7 @@ export const useAuthStore = create<AuthState>()(
useCustomTitlebar: false,
preloadMiniPlayer: false,
linuxWebkitKineticScroll: true,
linuxWaylandTextRenderProfile: 'sharp',
loggingMode: 'normal',
nowPlayingEnabled: false,
lyricsServerFirst: true,
+7
View File
@@ -90,6 +90,12 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
? {}
: { queueDurationDisplayMode: 'total' as DurationMode };
const VALID_WAYLAND_TEXT_PROFILE = new Set<string>(['balanced', 'sharp', 'gpu', 'minimal']);
const rawWaylandProfile = (state as { linuxWaylandTextRenderProfile?: unknown }).linuxWaylandTextRenderProfile;
const linuxWaylandTextRenderProfileMigrated = VALID_WAYLAND_TEXT_PROFILE.has(rawWaylandProfile as string)
? {}
: { linuxWaylandTextRenderProfile: 'sharp' as const };
// The `animationMode` 3-state setting was removed; users on `'reduced'`
// or `'static'` collapse onto the former `'full'` path automatically as
// soon as the field is gone from the store. Strip the persisted field
@@ -142,6 +148,7 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
...wheelSmoothOneTime,
...seekbarStyleMigrated,
...queueDurationDisplayModeMigrated,
...linuxWaylandTextRenderProfileMigrated,
...discordCoverSourceMigrated,
};
}
+6
View File
@@ -24,6 +24,9 @@ export type ClockFormat = 'auto' | '24h' | '12h';
export type NormalizationEngine = 'off' | 'replaygain' | 'loudness';
export type DiscordCoverSource = 'none' | 'apple' | 'server';
/** Wayland + WebKit text/GPU profile (Settings → System, Linux only when available). */
export type LinuxWaylandTextRenderProfile = 'balanced' | 'sharp' | 'gpu' | 'minimal';
/** Integrated-loudness target presets (Settings + analysis). */
export type LoudnessLufsPreset = -16 | -14 | -12 | -10;
@@ -111,6 +114,8 @@ export interface AuthState {
preloadMiniPlayer: boolean;
/** Linux WebKitGTK: smooth wheel on when true; off only after explicit opt-out in Settings. */
linuxWebkitKineticScroll: boolean;
/** Linux Wayland + GPU compositing: WebKit text rasterisation profile (live, no restart). */
linuxWaylandTextRenderProfile: LinuxWaylandTextRenderProfile;
/** Runtime backend logging level. */
loggingMode: LoggingMode;
nowPlayingEnabled: boolean;
@@ -283,6 +288,7 @@ export interface AuthState {
setUseCustomTitlebar: (v: boolean) => void;
setPreloadMiniPlayer: (v: boolean) => void;
setLinuxWebkitKineticScroll: (v: boolean) => void;
setLinuxWaylandTextRenderProfile: (v: LinuxWaylandTextRenderProfile) => void;
setLoggingMode: (v: LoggingMode) => void;
setNowPlayingEnabled: (v: boolean) => void;
setLyricsServerFirst: (v: boolean) => void;
+2
View File
@@ -21,6 +21,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
| 'setUseCustomTitlebar'
| 'setPreloadMiniPlayer'
| 'setLinuxWebkitKineticScroll'
| 'setLinuxWaylandTextRenderProfile'
| 'setSeekbarStyle'
| 'setQueueNowPlayingCollapsed'
| 'setQueueDurationDisplayMode'
@@ -43,6 +44,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
setPreloadMiniPlayer: (v) => set({ preloadMiniPlayer: v }),
setLinuxWebkitKineticScroll: (v) => set({ linuxWebkitKineticScroll: v }),
setLinuxWaylandTextRenderProfile: (v) => set({ linuxWaylandTextRenderProfile: v }),
setSeekbarStyle: (v) => set({ seekbarStyle: v }),
setQueueNowPlayingCollapsed: (v) => set({ queueNowPlayingCollapsed: v }),
setQueueDurationDisplayMode: (v) => set({ queueDurationDisplayMode: v }),
+10 -3
View File
@@ -6,7 +6,8 @@
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: inset 0 0 0 1px var(--border-subtle);
transition: transform var(--transition-base), box-shadow var(--transition-base);
/* Instant shadow change — same WebKitGTK jitter as animated box-shadow on .artist-card. */
transform: translateZ(0);
}
.album-card:hover {
@@ -18,6 +19,9 @@
aspect-ratio: 1;
overflow: hidden;
background: var(--bg-hover);
contain: paint;
isolation: isolate;
transform: translateZ(0);
}
.album-card-cover img {
@@ -25,12 +29,12 @@
height: 100%;
object-fit: cover;
transform-origin: center center;
transform: scale(1);
transform: translateZ(0) scale(1);
transition: opacity 0.15s ease, transform 350ms cubic-bezier(0.16, 1, 0.3, 1);
}
.album-card:hover .album-card-cover img {
transform: scale(1.02);
transform: translateZ(0) scale(1.02);
}
.album-card-cover-placeholder {
@@ -42,3 +46,6 @@
color: var(--text-muted);
}
.album-card-info {
transform: translateZ(0);
}
@@ -7,7 +7,8 @@
border: 1px solid var(--border-subtle);
cursor: pointer;
overflow: hidden;
transition: transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base);
/* Instant border/shadow: animated box-shadow jitters on WebKitGTK/Wayland; cover zoom stays smooth below. */
transform: translateZ(0);
}
.artist-card:hover {
@@ -24,6 +25,9 @@
align-items: center;
justify-content: center;
color: var(--text-muted);
contain: paint;
isolation: isolate;
transform: translateZ(0);
}
.artist-card-avatar img {
@@ -31,12 +35,12 @@
height: 100%;
object-fit: cover;
transform-origin: center center;
transform: scale(1);
transform: translateZ(0) scale(1);
transition: opacity 0.15s ease, transform 350ms cubic-bezier(0.16, 1, 0.3, 1);
}
.artist-card:hover .artist-card-avatar img {
transform: scale(1.02);
transform: translateZ(0) scale(1.02);
}
.artist-card-avatar-initial {
@@ -64,6 +68,11 @@
display: flex;
flex-direction: column;
gap: 2px;
transform: translateZ(0);
}
.artist-card-info--center {
text-align: center;
}
.artist-card-name {
+2 -1
View File
@@ -1,4 +1,5 @@
/* ─ Artists Page ─ */
.letter-heading {
font-size: 13px;
font-weight: 700;
@@ -23,6 +24,7 @@
padding: var(--space-3);
border-radius: var(--radius-md);
transition: background var(--transition-fast);
transform: translateZ(0);
width: 100%;
text-align: left;
}
@@ -67,4 +69,3 @@
color: var(--text-muted);
margin-top: 2px;
}
+81
View File
@@ -104,6 +104,87 @@
contain: paint;
}
/* Browse pages: in-page overlay scroller — main route viewport does not scroll. */
.app-shell-route-scroll__viewport--inpage-split {
overflow: hidden !important;
}
/* Sticky toolbar + `OverlayScrollArea` body; shared by Artists, Albums, Composers, etc. */
.content-body.mainstage-inpage-split {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
box-sizing: border-box;
padding-right: 0;
transition: padding-top 0.2s ease;
}
.mainstage-inpage-split .mainstage-inpage-toolbar {
flex-shrink: 0;
}
.content-body.mainstage-inpage-split .page-sticky-header {
position: relative;
top: auto;
z-index: auto;
margin-right: 0;
transition: padding 0.2s ease;
}
.content-body.mainstage-inpage-split.mainstage-inpage--header-tight {
padding-top: 0;
}
.content-body.mainstage-inpage-split.mainstage-inpage--header-tight .page-sticky-header {
padding-top: var(--space-2);
padding-bottom: var(--space-2);
}
.content-body.mainstage-inpage-split.mainstage-inpage--header-tight .page-sticky-header .page-title {
font-size: 1rem;
}
/* Toolbar rows: use these class names instead of inline gap so tight mode can tighten. */
.mainstage-inpage-toolbar-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
.content-body.mainstage-inpage-split.mainstage-inpage--header-tight .mainstage-inpage-toolbar-row {
gap: 0.5rem;
}
.mainstage-inpage-toolbar-alpha-row {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: var(--space-4);
}
.content-body.mainstage-inpage-split.mainstage-inpage--header-tight .mainstage-inpage-toolbar-alpha-row {
margin-top: var(--space-2);
}
.content-body.mainstage-inpage-split.mainstage-inpage--header-tight .artists-alpha-btn {
padding: 0.15rem 0.4rem;
font-size: 11px;
}
.content-body.mainstage-inpage-split > .mainstage-inpage-scroll.overlay-scroll {
flex: 1;
min-height: 0;
}
.mainstage-inpage-scroll__viewport {
padding-bottom: var(--space-6);
padding-right: var(--space-6);
}
/* Sticky page header: keeps page title + filter/search bar visible while the
body scrolls. Negative horizontal margins (+ matching padding) make the
background flush with the scroll container edges so the sticky block looks
+2 -1
View File
@@ -281,7 +281,7 @@
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
transition: all var(--transition-fast);
transition: background var(--transition-fast), color var(--transition-fast);
cursor: pointer;
text-decoration: none;
position: relative;
@@ -313,6 +313,7 @@
flex-shrink: 0;
opacity: 0.7;
transition: opacity var(--transition-fast);
transform: translateZ(0);
}
.nav-link.active svg,
+30
View File
@@ -59,3 +59,33 @@ ol {
list-style: none;
}
/* Linux Wayland + GPU compositing (WebKitGTK): grayscale body AA can look washed
when surfaces are GL-composited; prefer LCD subpixel while compositing stays on. */
html[data-platform="linux"][data-linux-session="wayland"]:not(.no-compositing) {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
html[data-platform="linux"][data-linux-session="wayland"]:not(.no-compositing):is(
[data-wayland-text-profile="balanced"],
[data-wayland-text-profile="gpu"]
) body,
html[data-platform="linux"][data-linux-session="wayland"]:not(.no-compositing):is(
[data-wayland-text-profile="balanced"],
[data-wayland-text-profile="gpu"]
) #root {
-webkit-font-smoothing: subpixel-antialiased;
-moz-osx-font-smoothing: auto;
text-rendering: geometricPrecision;
}
/* "Sharp" live preview: greyscale AA reads crisper on some GL-composited stacks (WebKit Never is applied on restart). */
html[data-platform="linux"][data-linux-session="wayland"]:not(.no-compositing)[data-wayland-text-profile="sharp"]
body,
html[data-platform="linux"][data-linux-session="wayland"]:not(.no-compositing)[data-wayland-text-profile="sharp"]
#root {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
}
-1
View File
@@ -9,7 +9,6 @@
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: inset 0 0 0 1px var(--border-subtle);
transition: box-shadow var(--transition-base);
}
.song-card:hover {
+8
View File
@@ -9,11 +9,19 @@ export type OverlayScrollbarThumbMeta = {
* When shorter than the viewport, thumb size/position must use this or the
* thumbs bottom extends past the visible rail at max scroll.
*/
function overflowYAllowsUserScroll(overflowY: string): boolean {
return overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay';
}
export function computeOverlayScrollbarThumbMeta(
el: HTMLElement | null,
trackHeight?: number,
): OverlayScrollbarThumbMeta {
if (!el) return { thumbH: 0, thumbT: 0, visible: false };
const { overflowY } = getComputedStyle(el);
if (!overflowYAllowsUserScroll(overflowY)) {
return { thumbH: 0, thumbT: 0, visible: false };
}
const { scrollTop, scrollHeight, clientHeight } = el;
if (scrollHeight <= clientHeight + 1) {
return { thumbH: 0, thumbT: 0, visible: false };