mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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 l’interface est immédiat. L’accé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',
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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 и откройте снова.',
|
||||
};
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 再重新打开。',
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -73,6 +73,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
useCustomTitlebar: false,
|
||||
preloadMiniPlayer: false,
|
||||
linuxWebkitKineticScroll: true,
|
||||
linuxWaylandTextRenderProfile: 'sharp',
|
||||
loggingMode: 'normal',
|
||||
nowPlayingEnabled: false,
|
||||
lyricsServerFirst: true,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,11 +9,19 @@ export type OverlayScrollbarThumbMeta = {
|
||||
* When shorter than the viewport, thumb size/position must use this or the
|
||||
* thumb’s 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 };
|
||||
|
||||
Reference in New Issue
Block a user