mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +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:
+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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user