Artists.tsx already paginates the DOM via PAGE_SIZE + IntersectionObserver,
so the page never holds more than 50-100 cards at a time. The actual
overhead with a 5000-artist library was the filter pipeline running on
every render — including renders triggered by selection mode toggles,
view-mode switches, image-toggle, etc. — re-walking the full artists
array twice per render (letterFilter + search filter), then re-building
the list-view groups Record from scratch.
Wrap the pipeline in useMemo:
- `filtered` recomputes only when artists / letterFilter / filter change
- `visible` recomputes only when filtered / visibleCount change
(also keeps array identity stable across unrelated re-renders)
- `groups` + `letters` recompute only when visible / viewMode change,
and skip the loop entirely in grid view (where they're unused)
No new dependency. With 5000 artists, unrelated state changes
(selection toggle, click on a card, scroll past pagination boundary)
are noticeably smoother.
Confirmed locally on a ~5000-artist library.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cold-start bundle was a single 1.88 MB / 577 KB-gzipped chunk that
parsed every page in the app before first paint, including pages a
typical session never opens.
Two-part fix:
1. Lazy-load 10 rarely-visited pages via React.lazy() — each becomes
its own chunk that only downloads when the route is hit:
Settings, Statistics, Help, WhatsNew, DeviceSync, OfflineLibrary,
LabelAlbums, AdvancedSearch, FolderBrowser, InternetRadio.
Whole <Routes> tree is wrapped in <Suspense fallback={null}> — no
visible loading state, the browser cache hits on subsequent visits.
2. Vite manualChunks splits dependencies that change rarely from app
code: react/react-dom/react-router, the @tauri-apps/* family, and
i18next. Tauri auto-updater pulls smaller deltas when only app
code changed (vendor chunks stay byte-identical and cached).
Build output:
Before: 1.88 MB / 577 KB gz monolithic
After cold start: ~450 KB gz (index 370 + react 54 + tauri 6 + i18n 20)
= 22% smaller initial download
Plus 11 lazy chunks. Notably WhatsNew's bundled CHANGELOG (215 KB /
76 KB gz) is now off the cold-start path entirely.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The existing module-level Map<songId, CachedLyrics> only survived
within a session. After every app restart the lyrics fetch chain
(server → lrclib → netease) ran from scratch for every track the user
played, even ones whose lyrics were already resolved 5 minutes ago
before the previous session ended.
Add an IndexedDB layer that's only consulted on RAM miss:
- L1 (RAM): unchanged, sync, hit on tab switch / track repeat
- L2 (IndexedDB, async): hit after restart, hydrates RAM
- Network: unchanged fallback chain
Key format `${serverId}:${songId}` so the same songId on two different
Subsonic servers cannot collide.
TTLs: 90 days for resolved lyrics (they rarely change once shipped),
7 days for `notFound` entries (gives the user / admin a chance to add
lyrics later without an indefinite negative cache).
YouLyPlus mode skips L2 — a persisted entry from the standard pipeline
wouldn't have word-level sync, which is the whole point of lyricsplus.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The IntersectionObserver sentinel triggered the next page only when it
came within 200px of the viewport — by the time the user had scrolled
that far, they were already at the very edge waiting for the request.
Bumping rootMargin to 1500px (~1.5 screens at typical heights) prefetches
ahead of the scroll instead of behind it, so the next batch of albums is
visibly already there when the user reaches them.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fetchTracksForSource() iterated over an artist's albums with sequential
`await getAlbum(...)` inside a for-loop. A 50-album artist sync stalled
for ~7 seconds of round-trips before any device write started.
Switch to Promise.all(albums.map(...)). Same pattern ArtistDetail.tsx
already uses for "Play All" without issues — Navidrome handles parallel
getAlbum requests fine.
Per-album errors fall back to an empty song list rather than aborting
the whole sync.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sidebar LiveSearch and mobile search overlay rendered cover thumbs via
raw <img src={buildCoverArtUrl(...)}> instead of going through the
IndexedDB-backed CachedImage path used everywhere else in the app.
buildCoverArtUrl() mints a fresh URL on every call (random salt + a new
MD5 token per Subsonic spec), so the browser cache was permanently
defeated for these thumbs — every re-render of the dropdown produced
new URLs and the browser dispatched HTTP requests for every "new"
image.
While typing in the search field this multiplied: each keystroke
triggered a re-render with fresh URLs for every visible cover, and
after the 300 ms debounce a fresh search() result triggered another
wave. Five visible album thumbs × five keystrokes ≈ 50 redundant cover
fetches in 1.5 s, manifesting as periodic typing delays.
Switch all three call sites to CachedImage with the stable
coverArtCacheKey() — same pattern CLAUDE.md explicitly recommends:
buildCoverArtUrl() generates a new ephemeral URL every call —
browser cache is useless. Use coverArtCacheKey(id, size) +
CachedImage for <img> tags.
Confirmed locally: typing in the search field is noticeably smoother.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(queue): add Now-Playing Info tab with artist bio, song credits and Bandsintown tour dates
A third tab in the right-side queue panel surfaces context for the
currently playing track:
- Artist card: image + biography from Subsonic getArtistInfo (Last.fm
"Read more on Last.fm" anchor stripped), with read-more toggle that
only appears when the bio actually overflows the 4-line clamp.
- Song info: contributor credits from OpenSubsonic contributors[]
rendered stacked (name prominent, role muted). Section is hidden
entirely on servers without contributor support, and rows that just
re-state the main artist under role "artist" are filtered out.
- On tour: optional Bandsintown integration (opt-in, off by default).
HTTP fetch + JSON parsing happen entirely on the Rust side; the
frontend wrapper deduplicates concurrent calls and caches results in
RAM for the session. Limited to 5 events with a "Show N more" toggle.
When the toggle is off, the section becomes an in-place opt-in card
with a privacy info-tooltip explaining what data is sent — same
tooltip is also exposed on the matching toggle in Settings.
Caching: artist info and song detail are memoised by stable IDs across
component remounts, so jumping between tracks of the same album/artist
does not refire the network calls.
Implementation notes:
- Bandsintown app_id "js_app_id" — arbitrary strings (e.g. "psysonic")
now return HTTP 403 from rest.bandsintown.com; js_app_id is the ID
Bandsintown's own embeddable widget uses and is broadly accepted.
- Tour items use negative left/right margins so the date badge stays
visually aligned with the section title while the hover background
extends slightly past the section edges.
- New i18n namespace nowPlayingInfo across all 8 locales (en, de, fr,
nl, zh, nb, ru, es) including pluralised "Show N more" forms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(now-playing-info): switch nested flex-columns to block layout to stop content-dependent drift
Repeated reports of the Tour and Song-info sections rendering at
inconsistent x-positions (sometimes left of the section title,
sometimes centred, sometimes correctly aligned) — varying by artist,
sidebar width, and even whether "Show more" was clicked.
Root cause: nested flex-direction: column containers
(.np-info → .np-info-section → .np-info-tour / .np-info-credits) where
the cross-axis (horizontal) sizing on WebKitGTK occasionally inherits
the intrinsic min-content of the longest child. With one
"Naherholungsgebiet/Freizeitgelände Lago Alfredo"-style venue name
that overflows the sidebar, the parent ul gets pushed past 100% width
and the entire layout drifts. min-width: 0 on every level didn't help
because cross-axis stretch in flex-column ignores it for content-driven
overflow.
Fix: collapse all the section/list containers to display: block. Block
layout is content-agnostic — children always render at 100% parent
width, left-aligned, deterministically. Spacing between siblings now
uses the lobotomy selector (`> * + * { margin-top }`).
Only the tour item itself stays flex (badge ↔ meta horizontal layout),
and that one still has overflow: hidden + flex: 1 1 0 + min-width: 0
on the meta column to truncate venue/place text with ellipsis.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto mode is the new default for ReplayGain. It picks album gain when an
adjacent queue neighbour shares the same albumId (so "Play All" on an
album, or any contiguous album block, gets album gain), otherwise track
gain. Falls back to track gain when album gain is missing.
- authStore: replayGainMode widened to 'track' | 'album' | 'auto', default 'auto'
- playerStore: new resolveReplayGainDb(track, prev, next, enabled, mode) helper
replaces five inline ternaries (audio_play hot path, gapless preload,
cold-resume success + fallback, live update_replay_gain)
- Settings: third "Auto" button + contextual hint when active
- i18n: replayGainAuto + replayGainAutoDesc added to all 8 locales
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(titlebar): hide traffic-light glyphs until hover
The lucide Minus/Square/X icons at 9×9 with stroke-width 2.5 looked
chunky at all times — especially the Square for maximize. Match real
macOS behaviour: glyphs fade in only when the controls group is hovered
(or a button is keyboard-focused).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(titlebar): drop traffic-light glyphs entirely, add coloured hover glow
Following review feedback: even on-hover the lucide glyphs at 9×9
weren't pleasant. Remove them outright and signal hover with a soft
coloured glow matching each button (red/yellow/green) plus a subtle
brightness lift. Keyboard focus uses a white outer ring for accessibility.
- TitleBar.tsx: remove lucide imports + icon children, add aria-label
- layout.css: drop svg sizing rules, add per-variant box-shadow hover,
add focus-visible ring, transition box-shadow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Use the main window WebView user agent as the runtime source for Rust-side HTTP clients and refresh the audio engine client when the UA changes. This keeps backend and WebView requests aligned while preserving a simple startup sync flow.
Add a System setting for Off/Normal/Debug logging, apply readable local timestamps to backend logs, and enable exporting buffered runtime logs to a file when debug mode is active.
Co-authored-by: Maxim Isaev <im@friclub.ru>
* fix(player): prevent streaming seek UI freezes and progress snapbacks
Move blocking seek work off the command path with a bounded timeout, switch fullscreen/mobile seek bars to commit-on-release, and stabilize fallback progress state so rapid early seeks on streamed tracks do not freeze the UI or jump progress to zero.
* fix(player): avoid second-seek lockups and progress flicker
Add a short lock timeout for audio seek state access to prevent UI stalls when rapid seeks overlap, and keep waveform progress stable right after seek commit to eliminate brief visual snapbacks.
* fix(player): harden seek recovery and reduce stream log noise
Keep seek fallback stable during delayed backend responses to prevent occasional resets to track start, and remove per-read stream blocking logs so normal playback diagnostics stay readable.
---------
Co-authored-by: Maxim Isaev <im@friclub.ru>
RandomMix: filters and genre mix panels collapse on mobile
- RandomAlbums / album grids: auto-fill favouring 3 columns at narrow widths
- BottomNav: More button opens a bottom sheet with remaining nav items
- MobileMoreOverlay: new sheet component with backdrop and slide-up animation
- Tracklist: mobile Title / Artist two-line stacked layout (Apple Music style)
- ArtistDetail: centred header (photo → name → buttons), icon-only buttons
except Play All, collapsible Similar Artists (5 default), 2-column album
grid, avatar glow derived from dominant cover colour
- Settings: fixed overflow in ReplayGain, Crossfade, mix rating filters,
theme scheduler, and Next Track Buffering sections
Co-authored-by: kilyabin <65072190+kilyabin@users.noreply.github.com>
GitHub rejects \`gh pr merge --auto\` when the PR has no required
status checks and no required reviews — the error is "Pull request
is in clean status (enablePullRequestAutoMerge)". Our ruleset
requires neither, so auto-merge has nothing to wait for. Switch
to a direct merge instead. If we ever add required checks, --auto
can come back.
Validated against the v1.43.0 test release: PR #226 was created
by the workflow but the auto-merge call failed; merging it
manually with the same flags minus --auto succeeded immediately.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After enabling branch protection rulesets on main, the existing
direct push from the verify-nix job (`git push origin HEAD:main`)
is rejected. Push the refresh commit to a per-release branch
(`chore/nix-lock-refresh-v<VERSION>`) instead, open a PR, and
enable auto-merge so the change lands as soon as repo conditions
allow (0 required reviews + 0 required status checks → instant).
Adds `pull-requests: write` to job permissions so GITHUB_TOKEN can
create + merge the PR.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tightens the Settings panels globally: section margin-bottom 32→20px,
card padding 20→12px, divider margin 12→8px, section header h2
16→15px. Adds a subtle accent-tinted hover background on every
.settings-toggle-row (extended to card edges via negative
horizontal margin). Vertical row padding kept at 2px so the diet
isn't undone by the hover affordance.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Maps Navidrome's lastAccessAt onto NdUser and renders it in the
User Management row as a localized relative time (Intl.RelativeTimeFormat
with the current i18n language). Tooltip carries the absolute
timestamp. Navidrome returns 0001-01-01 for never-accessed users —
detected and shown as the localized "Never" label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Navidrome library assignment to the User Management settings
panel: GET /api/library + PUT /api/user/{id}/library wired through
new Rust commands. UserForm gets a checkbox picker (hidden for
admins, who auto-receive all libraries server-side) with inline
validation. User rows compacted to a single clickable line with
hover highlight; native confirm() replaced by a portal-based
ConfirmModal (theme-aware, ESC/Enter, vertically centered).
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restore the main-window minimize/unminimize behavior when opening the
mini player on Windows — the original WebView2 stall no longer applies
since the mini window is pre-created at startup. Also cap the mini
window at 400 px width so horizontal drag can't stretch the layout
across a whole monitor; skipped on tiling WMs (Hyprland, Sway, i3)
where the compositor manages sizing itself.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructure the MiniPlayer to mirror the main window's queue panel layout:
UI changes:
- Meta block: cover + title/artist/album/year (year added to MiniTrackInfo)
- Action toolbar styled like .queue-round-btn (30px round, accent fill when
active), in order: volume, shuffle | gapless, crossfade, infinite | queue
- Volume button opens a thin 5px vertical strip slider that drops down (click
/ drag / wheel to adjust). Right-click on the volume button mutes.
- Controls + progress moved to the bottom as a true footer (margin-top: auto),
so they stay anchored even with the queue expanded
- Queue toggle moved out of the titlebar into the action bar (logically lives
with the other queue/playback toggles)
- Window size bumped to 340x260 collapsed / 340x500 expanded for the new layout
Bridge changes (miniPlayerBridge.ts):
- MiniSyncPayload extended with volume, gaplessEnabled, crossfadeEnabled,
infiniteQueueEnabled
- Bridge now subscribes to authStore in addition to playerStore so toolbar
toggle states propagate cross-window
- New events: mini:set-volume, mini:shuffle, mini:set-gapless, mini:set-crossfade,
mini:set-infinite-queue
- Bridge enforces gapless ↔ crossfade mutual exclusion (per CLAUDE.md gotcha)
so the mini doesn't need to know about both states to act
Misc:
- Belt-and-suspenders user-select: none on .mini-player-shell * to kill
Ctrl+A / mouse-drag selection that WebKit/WebView2 occasionally let through
Ports the WaveformSeek UX changes from PR #219 (original fix by @cucadmuh)
onto test/seekable-streaming:
- Split previewFraction (during drag) / commitSeek (on mouseup) so audio_seek
fires once per gesture instead of on every mousemove.
- Subscribe-effect skips external progress updates while isDragging — prevents
cursor flicker from streaming/recovery ticks fighting the dragged position.
- Clear seekTarget in seek() catch branch so a failed seek doesn't permanently
block progress updates.
Skips the byte-restart fast-path from #219: would defeat RangedHttpSource's
direct seek by restarting the stream after 450ms.
Co-Authored-By: cucadmuh <cucadmuh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reworks the manual streaming-start path so playback is seekable from the
first frame and hot-cache hits start without a multi-hundred-MB pre-read.
Why
- The legacy AudioStreamReader was a non-seekable ring buffer. Forward
seeks during streaming let Symphonia consume the buffer through to EOF
(next song), and backward seeks were silently broken.
- fetch_data() loaded `psysonic-local://` files via tokio::fs::read,
blocking playback for seconds on hot-cache or offline files (e.g. a
100+ MB hi-res FLAC) and producing ALSA underruns when the new sink
swap arrived too late.
What changed
- New RangedHttpSource MediaSource: pre-allocates a Vec<u8> in track
size, background ranged_download_task fills linearly from offset 0
with HTTP Range reconnects. is_seekable=true; reads block on data,
seeks update the cursor only. Picked when the server response carries
Accept-Ranges: bytes + Content-Length.
- New LocalFileSource MediaSource: thin std::fs::File wrapper used for
every `psysonic-local://` URL — Symphonia probes the first ~64 KB and
starts playback before the rest is read.
- audio_play wires both paths through a generic PlayInput::SeekableMedia
variant so the build_streaming_source pipeline stays single-shape.
- current_is_seekable AtomicBool on AudioEngine: audio_seek short-circuits
with `not seekable` for the legacy fallback so the frontend restart-
fallback (playerStore seek catch) engages instead of letting a forward
seek consume the ring buffer.
- Dev-only [stream] / [seek] eprintln logs (cfg(debug_assertions)) so
source selection, codec resolution (codec name + lossless flag + bit
depth + rate + channels), download progress and seek targets are
visible in the terminal during testing.
- content_type_to_hint now covers wav/wave/opus; URL-derived format
hints strip the query string first and only accept known audio
extensions (Subsonic stream URLs have no extension — would otherwise
latch onto query-param fragments).
Compatibility
- Servers without Accept-Ranges fall back to the existing AudioStreamReader
path; seek is rejected up-front so the existing restart-fallback runs.
- Radio + audio_chain_preload paths untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Active nav-link: raise background to 22% accent + switch text/icon to
white — lifts from 3.30:1 to >10:1 (was AA-small fail). Left purple bar
preserves the Jellyfin brand cue.
Primary buttons (btn-primary, player-btn-primary, hero-play-btn,
album-card-details-btn, queue-round-btn.active): force font-weight 700.
White on #AA5CC3 measures 4.08:1 — bold glyphs compensate visually so
button labels remain clearly legible without shifting the brand color.
Not touched: accent-as-text on bg-card (4.09:1, needs separate
--accent-text token), hover tint #be70d8, white on danger red
(cross-theme issue).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a dedicated Settings tab for managing Navidrome users (list, create,
edit, delete). Only visible to admins — gated by `/auth/login` probe on
mount. Uses Navidrome's native /api/user endpoints instead of Subsonic
(getUsers.view etc. return only the caller on Navidrome). All HTTP calls
go through new Rust commands to bypass WebView CORS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up on #217. Blind A/B test on NVIDIA + proprietary confirmed the
toggle helps, but the detection path had a few rough edges.
- Drop lspci and glxinfo fallbacks: the /proc/driver/nvidia/version +
sysfs scan catch every realistic case, and spawning subprocesses in
main() added startup latency for no win (glxinfo also isn't always
installed — mesa-utils isn't a hard dep).
- Scan all /sys/class/drm/card* entries via read_dir instead of
hardcoded card0/card1 — hybrid laptops and systems where only card1
exists were previously missed.
- Remove 0x1022 from the AMD list: that's AMD's host/chipset vendor ID,
not GPU. Only 0x1002 (ATI/Radeon) belongs here.
- Unknown vendor → leave WEBKIT_DISABLE_DMABUF_RENDERER unset instead
of forcing =1. VMs, ARM SBCs and anything exotic keep the WebKitGTK
default and do not get regressed by a guess.
- Only set the env var for the NVIDIA case. Intel/AMD on Mesa already
default to DMA-BUF enabled in current WebKitGTK, so the explicit =0
was redundant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inspired by kveld9's PR #211 proposal. Adapted to stay within WebView2
and WKWebView GPU budgets — 8px blur instead of 28, theme-token colours
via color-mix instead of hardcoded white, no saturate/brightness/
contrast stack, no transitions, no !important.
Linux keeps the solid floating bar from PR #216 on every compositing
mode. Platform is detected once on mount via a data-platform attribute
on <html>.
Co-Authored-By: kveld9 <179108235+kveld9@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both AppShell and PlayerBar read floatingPlayerBar via destructured
useThemeStore() without a selector, so every unrelated theme change
(accent, font, scheduler state, …) would force a re-render. Switch to
s => s.floatingPlayerBar so these components only re-render when the
toggle actually flips.
Follow-up to PR #216.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After PR #214 replaced SpringScroller with EaseScroller, the filename
no longer matched the exported class. Rename for clarity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Synced lyrics always fill the screen due to auto-scroll, so the
existing fsa-fade-bottom overlay naturally fades content below
the active line. Plain (unsynced) lyrics start from the top and
may not fill the viewport, leaving no content for that overlay
to fade.
Add mask-image to .fsa-lyrics-container when rendering plain
lyrics (--plain modifier class). The mask operates on the visible
scroll viewport rather than the content, so the bottom fade is
always present regardless of scroll position.
The spring model (velocity × damping per rAF frame) produced an
abrupt-looking lurch on each line change — especially noticeable at
the stiffness/damping values used (0.1 / 0.78).
Replace SpringScroller with EaseScroller: a fixed-duration (650 ms)
cubic ease-out animation that starts from the current scroll position
and decelerates smoothly to the target. Restarting mid-flight is clean —
the next call captures the container's current scrollTop as the new
start point, so fast line changes never skip or jerk.
Applied to both the fullscreen Apple-style lyrics and the sidebar
LyricsPane. User-scroll cancellation (stop() + 4 s resume) is
preserved unchanged.
The psy-drop listener was attached in a useEffect keyed only on
setLyricsSources, so when the container was unmounted by the
{lyricsMode === 'standard' && …} wrapper (switching to YouLyPlus and
back), the listener stayed bound to the old DOM node and drag-to-reorder
silently did nothing.
Switch the container ref to a callback-ref stored in state, so the
effect re-runs on mount/unmount and always listens on the current DOM
node.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This reverts commit 77edfaa867.
Restores the full Apple Music-style lyrics visuals from PR #205 on Linux:
rAF spring scroller, per-line scale transforms, text-shadows, title glow,
album-art/play-button outer glows, mesh-blob radial gradients, and the
FS-art opacity crossfade. PR #205's author noticed the Linux codepath
lost the intended feel — the CPU tradeoff is accepted.
The trigger-ref fix from 25537f27 for the lyrics menu stays in place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace hardcoded #fff on `.folder-col-row.selected` with a relative
OKLCH expression that picks black or white based on the accent's
lightness. Keeps rows readable on bright-accent themes (Muma, pastel
sets) without per-theme overrides.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>