* Enhance CachedImage and ArtistDetail components with improved image caching and priority handling
- Refactor CachedImage to utilize a priority system for image loading based on viewport visibility, improving performance during scrolling.
- Update useCachedUrl to accept an optional getPriority function for better cache management.
- Optimize ArtistDetail and Artists components by using useMemo for cover art URLs, reducing redundant calculations and improving rendering efficiency.
- Adjust image loading logic in CachedImage to ensure smoother transitions and avoid unnecessary fetch requests.
* perf(ui): unblock IDB cover art, stabilize mainstage rails and virtual lists
Let IndexedDB reads bypass the network concurrency slot so cached thumbnails
paint without queueing behind remote fetches; debounce disk eviction during
heavy scrolling.
Fix mainstage horizontal rails: dedupe album/song ids for React keys, widen
artwork budget overscan, avoid resetting the budget on list append, and raise
Home initial artwork budgets. CachedImage treats already-decoded images as
loaded; rail cards load cover images eagerly.
Refresh dynamic color extraction and extend virtual scrolling / scroll roots on
Albums, Artists, Playlists, and related surfaces.
Remove local agent-only commit instructions from the repository tree.
* perf(virtual): viewport-based overscan for main scroll lists
Drive TanStack Virtual overscan from measured scroll height so each list
renders about one screen of extra rows above and below the viewport for
snappier scrolling on Albums, Artists (list mode), and Tracks virtual song list.
Introduce useResizeClientHeight helpers (ID + ref) for ResizeObserver-based
clientHeight tracking.
* docs(changelog): note PR #468 UI cover cache, rails, and virtual lists
Add a coarse summary under 1.46.0 Changed for cover-art pipeline,
mainstage rails, viewport-based overscan, and library/chrome polish.
* feat(shortcuts): unify action-driven shortcut and CLI routing
Centralize shortcut action metadata in one TypeScript registry and route keyboard, global shortcut, mini-window, and CLI inputs through shared runtime handlers.
Keep CLI as an abstract transport layer by emitting player-command payloads without depending on shortcut definitions.
* feat(shortcuts): generate CLI action help from shortcut registry
Move no-arg player commands and their descriptions into the central action registry so CLI parsing and --player help are derived dynamically from one source of truth. Also route runtime action execution through the registry and remove duplicated shortcut runtime handling.
* feat(shortcuts): add new input actions and hidden F1 help binding
Add the requested input actions (search, advanced search, sidebar, mute, equalizer, repeat, now playing, lyrics, favorite current track) to the central shortcut action registry and wire runtime handlers for sidebar/equalizer toggles. Keep Help bound to F1 by default while hiding it from Settings input lists, and backfill persisted keybindings with new defaults so F1 works for existing users.
Requested by @zunoz (Discord community).
The squash-merge of PR #419 was performed against an outdated PR base
that predated several main-side refactors and features. The resulting
squash inadvertently re-introduced files that had already been removed
(`src-tauri/src/audio.rs` monolith, `app-icon.png`) and reverted main's
content for ~20 files (`src-tauri/src/lib.rs` decompose, `src/App.tsx`
animation-pause, `src/components/AlbumRow.tsx` headerExtra, etc).
This commit:
* Restores all collateral-damage files to their pre-#419 main state
(5662dafe), including the audio module split, lib decompose,
Cargo.toml `windows` dep, animation-pause-on-blur logic, and
`reducedAnimations` toggle plumbing in WaveformSeek/Settings.
* Keeps the genuine #419 work intact: tri-state duration toggle,
position counter, persistent Now Playing collapse, animated EQ
indicator (in QueuePanel.tsx, 8 locales, authStore, components.css).
* Merges authStore.ts so both `reducedAnimations` (from #426) and
`queueNowPlayingCollapsed` (from #419) coexist.
* Adds the `.eq-bars.paused` CSS rule manually since components.css
needed a 5662dafe base + the single #419 addition.
* Fixes a latent type error in OverlayScrollArea.tsx (`useRef<T>(null)`
is `RefObject<T>` / read-only under current `@types/react`; widened to
`useRef<T | null>(null)` so the existing `wrapRef.current = el`
assignment compiles).
Verified locally with `cargo check` and `npm run build` — both green.
* feat(queue): add ETA display, equalizer indicator and collapsible now playing
* deleted endsAt and showDuration strings, changed eta update to 30s
* feat(queue): ETA tooltip, persistent Now Playing collapse, EQ bar pause, remove redundant Play icon
* feat(queue): fold ETA into existing total/remaining toggle as third mode
The standalone ETA span next to the track counter is removed; instead the
clickable duration label in the queue header now rotates through three
modes per click: total → remaining → eta → total. Counter (N/M) stays
where it was.
ETA mode keeps the live-feel treatment from the original PR (accent
colour while playing, muted at 50% opacity when paused). The other two
modes use plain accent.
i18n: queue.etaTooltip removed (no longer a separate descriptive label),
queue.showEta added as the action tooltip ('Show estimated end time')
in all 8 locales — matches the showRemaining / showTotal pattern.
* docs(changelog): add #419 queue UX improvements entry
Adds the [1.45.0] / Added entry for this PR's queue panel refinements
(position counter, tri-state duration toggle including ETA, collapsible
Now Playing section, animated EQ indicator).
---------
Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
Snapshot queue, current track, time, and pause before queue mutations.
Ctrl+Z/Cmd+Z restores prior state; Ctrl+Shift+Z/Cmd+Shift+Z reapplies undone
edits when the redo stack is non-empty; new edits clear redo.
Resync the Rust audio engine only when the current song identity changes
from the pre-apply state so reorder/enqueue-style edits keep live playback.
Register the document capture listener from main.tsx after the window label
is set. Mini player forwards undo/redo via mini:undo-queue and mini:redo-queue.
Reported in #274 — the icons for the Gapless and Infinite Queue toggles
were the wrong way round. The infinity symbol was on Gapless, but reads
much more naturally on Infinite Queue (a queue that never ends), and the
arrow-up-to-line on Infinite Queue had no obvious mapping at all.
Move infinity to Infinite Queue. Use MoveRight (right-pointing arrow) for
Gapless — visualises 'flows straight through to the next track without
stopping' and stays distinct from the chain/link family at 13 px in both
the QueuePanel and Mini Player toolbars.
Closes#274
`.mini-player` has `overflow: hidden`, and the absolute-positioned volume
popover (`top: calc(100% + 6px)`, ~150 px tall) was being clipped when the
queue was closed and the mini window was at its short height — visually
the slider rendered outside the visible area, even though it stayed
fully usable. Opening the queue grew the window enough to bring the
popover back inside the clip rect.
Render the popover via `createPortal` to `document.body` and position it
with `position: fixed` based on the trigger button's `getBoundingClientRect`,
mirroring the YearFilterButton pattern. Auto-flips above when there is
not enough room below (short mini windows). Repositions on window
resize / scroll. Outside-click handler now checks both the trigger button
and the popover ref since they no longer share a DOM ancestor.
- Type `window.__psyHidden` once via global declaration; drop 6 `(window as any)` casts.
- `WindowVisibilityProvider` now ORs `window.__psyHidden` into the hidden state
(and into the initial state). On WebView2 `document.hidden` does not always
flip when `win.hide()` is called, so effects gated by `useWindowVisibility()`
can now actually skip creating intervals/rAF instead of relying solely on the
per-tick fallback check.
- Slow visible-poll from 200 ms to 500 ms (5 → 2 wakeups/s for visibility alone).
- Fix stray leading-space indent in `App.tsx` `window:close-requested` comment.
Co-authored-by: Psychotoxical <dev@psysonic.app>
* fix(windows): stop GPU rendering when windows are hidden
* perf(ui): tighten hidden-window rendering mitigation
Align implementation and documentation with what the injected pause/resume
scripts actually do, reduce main-thread and compositor wakeups while windows
are invisible, and make lifecycle cleanup reliable.
Rust (Tauri)
- Document PAUSE/RESUME as compositor-oriented hints: set __psyHidden, optional
--psy-anim-speed for CSS that opts in, and pause @keyframes via
animation-play-state only (not CSS transitions, not arbitrary timers or rAF).
- Walk descendants under #root instead of document-wide querySelectorAll('*')
to cut cost on large pages; skip the walk if #root is missing (flag still set).
- Call eval(PAUSE_RENDERING_JS) before hide() on all hide paths (tray menu,
tray icon toggle, mini open/close/show-main, native mini close) so the
webview sees __psyHidden while still fully schedulable.
- Shorten pause_rendering command rustdoc to match the script.
Frontend
- WindowVisibilityProvider: replace self-rescheduling useCallback with a single
effect that keeps one pending timeout id, uses a cancelled flag on unmount,
and syncs hiddenRef from document.hidden at mount (no orphaned timers after
tests/HMR).
- WaveformSeek (preview + animated seekbar): while hidden, poll with a 400 ms
timeout instead of requestAnimationFrame every frame; cancel both rAF and
timeout on teardown.
Why
- Accurate comments prevent future refactors from assuming “full JS idle”.
- Smaller DOM walks and fewer rAF wakeups reduce spikes when hiding the main
window or mini player on Windows WebView2 and elsewhere.
* perf(ui): pause global CSS + timers when Tauri hides the window
Builds on PR #273 (`7803d8e` + `0e07a73`). Adds a second HTML flag driven only by
Rust pause/resume inject so infinite animations still pause when WebView2 keeps
`document.hidden === false` after `win.hide()`, and stops a few periodic JS
timers while the window is invisible.
- `lib.rs`: set/remove `data-psy-native-hidden` on `<html>` in PAUSE/RESUME JS;
document why in rustdoc (rest of pause/resume unchanged from PR tip).
- `components.css`: same `animation-play-state: paused !important` rules as
`data-app-hidden` for `[data-psy-native-hidden="true"]`; refresh the comment.
- `App.tsx`: comment distinguishes tab visibility vs Tauri-native hide.
- `Hero.tsx`: do not run the 10 s carousel interval while hidden
(`useWindowVisibility` + `__psyHidden` in tick).
- `PlaybackScheduleBadge.tsx`, `playbackScheduleFormat.ts`: no 400/500 ms
intervals while hidden; skip ticks when `document.hidden` or `__psyHidden`.
Add OverlayScrollArea with shared thumb metrics and drag handling; size the
thumb against the rail track height so panel insets cannot push it past the
visible rail. Route scroll uses a stable viewport id; Genres restores scroll
and infinite-scroll observation against that viewport (merged with upstream
virtualized genres list and lazy routes behind Suspense).
Suppress queue resizer activation when the pointer targets the main-route
overlay scrollbar; disable the resizer while dragging the thumb and use a
grabbing cursor on the body.
Apply WebKitGTK smooth wheel to the mini webview as well as main; the mini
window reapplies the persisted setting after auth store hydration.
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
Creating the second WebView2 webview lazily from the open_mini_player
invoke handler reliably stalled Tauri's event loop on Windows — the mini
opened blank white, neither main nor mini could be closed, and the user
had to kill the process via Task Manager. The builder-then-minimize-main
combo racing with WebView2's first paint seems to be the trigger.
Fix: extract a build_mini_player_window helper and call it once in
.setup() on Windows so the webview is created (hidden) in the startup
context, not from an invoke command. open_mini_player is now a pure
show/hide on all platforms. Windows also skips the main.minimize() call
since that interacted with the hang; macOS + Linux keep the existing
behaviour of minimising main when the mini opens.
Other changes bundled in:
- Switch Windows from the custom in-page titlebar back to native window
decorations (the earlier decorations(false) move on Windows was part
of the hang surface — safer to keep the OS chrome there, Linux still
uses the custom bar).
- Re-emit mini:ready on window focus so every open of the pre-created
mini forces a fresh snapshot from main's bridge, even when the mount-
time emit raced past the bridge during startup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The custom titlebar was rendered only on Win/Linux, so macOS users had
no way to toggle the queue, pin-on-top or expand back to the main
window — the native macOS titlebar takes care of dragging + close, but
those three actions live entirely in app code.
Render a slim transparent in-page strip on macOS as well, holding just
the queue / pin / maximize buttons right-aligned. Close is omitted on
macOS since the red traffic light already does that.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop the native titlebar on Windows + Linux (decorations: false in
open_mini_player); macOS keeps the system titlebar with traffic lights.
- Add a slim 26 px custom titlebar with drag region, current track
title, and the queue / pin / open-main / close action icons.
- Remove the bottom toolbar — those four buttons now live in the
titlebar where they're easier to reach.
- Refresh the player layout: cover shrinks 112 → 84 px, the right
column gets title / artist / transport in a single 84 px-tall block,
progress bar spans the full width below.
- Add a miniPlayer i18n namespace (showQueue, hideQueue, pinOnTop,
pinOff, openMainWindow, close, emptyQueue) localized for all 8
supported locales — previously the tooltips were hard-coded English.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an 'open-mini-player' entry to the in-app keybindings (Settings →
Shortcuts), default unbound. Pressing the chord from the main window
opens the mini and minimises main; pressing it from the mini hides the
mini and restores main — both paths invoke open_mini_player which
already handles the toggle.
The mini window has its own keydown listener (same pattern as Space /
arrows for play/skip) that reads the binding from useKeybindingsStore.
Binding changes in main propagate to an open mini through the existing
storage-event sync (now also covers psysonic_keybindings) so the user
doesn't need to restart the mini after rebinding.
Localized in all 8 supported locales.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two regressions in the mini player's persistence story:
1. Window position was lost on every reopen. set_position() called on a
hidden window is unreliable on Linux WMs (Mutter, KWin re-centre on
show). Worse, the WM-induced re-centre fired WindowEvent::Moved with
the centre coords, which the throttled persister happily wrote to
disk — so the saved position turned into "centre" within seconds.
Fix:
- Initial open uses WebviewWindowBuilder::position() (logical pixels,
scaled via the primary monitor) so the window is created at the
right spot.
- Re-show path re-applies the saved position with set_position AFTER
show().
- Both paths call mark_mini_pos_programmatic() before triggering the
move; persist_mini_pos_throttled ignores Moved events within 1 s of
a programmatic mark, so WM-induced and self-induced moves no longer
overwrite the user's position.
2. The queue panel always opened collapsed regardless of the previous
session. Persist queueOpen to localStorage (psysonic_mini_queue_open)
and resize the window to the stored expanded height on mount when
the saved state was 'open'. Brief jump from 180 px to expanded is
unavoidable since localStorage only lives in the JS layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This iteration fills in the pieces that make the mini player usable as a
daily standalone window:
- Window position persists to <app_config_dir>/mini_player_pos.json on
every WindowEvent::Moved (throttled 250 ms); first launch lands in the
bottom-right of the main window's monitor.
- Queue keeps a 260 px floor when expanded (~2 visible rows). The
user-resized expanded height is restored on next toggle via
localStorage so reopening doesn't snap back to the default 440 px.
- set_mini_player_always_on_top(true) now forces a false-then-true cycle
so the WM re-evaluates the layer after a hide/show; the frontend also
re-asserts the pin state on mount and on window focus, so the user no
longer has to click the pin button twice for it to stick.
- Native scrollbar in the queue is hidden in favour of a JS-driven
overlay thumb so items use the full width of the queue area.
- PsyDnD reorder works inside the mini queue: drag emits 'mini:reorder',
the bridge calls reorderQueue on the source-of-truth store in main.
- Localized queue-item context menu (MiniContextMenu) with Play now /
Remove from queue / Open album / Go to artist / Favorite / Song info.
Cross-window actions forward to main via new bridge events
(mini:reorder, mini:remove, mini:navigate, mini:song-info); a
psy:navigate CustomEvent picked up by AppShell handles routing.
- Theme, font and language changes in main propagate live to the mini
via a 'storage' event listener that re-hydrates the persisted Zustand
stores and calls i18n.changeLanguage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a queue panel that toggles via a new toolbar button and resizes the
mini window between 340×180 (collapsed) and 340×440 (expanded). Tracks
in the queue are clickable — click jumps to that index via a new
mini:jump event handled in the main-window bridge. The current track
auto-scrolls into view when the queue opens.
Resize uses a new resize_mini_player Rust command (more reliable than
JS setSize, which was silently no-op'ing because the
core:window:allow-set-size capability was missing). That capability is
now granted as well.
The mini player hydrates its initial state from the persisted
playerStore, so real content (cover, title, artist, queue) shows as
soon as React mounts instead of "—" while we wait for the first
mini:sync. Dynamic state (isPlaying, progress) still comes from
events.
Window sizing:
- inner_size bumped to 340×180 so the toolbar row isn't clipped
- mini label added to tauri-plugin-window-state denylist so old saved
sizes don't come back
- show_main_window now also hides the mini — clicking expand no longer
leaves both windows on screen
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A second webview window (label "mini") with a compact player: album art,
title, artist, prev/play/next, progress bar, pin-on-top toggle, expand
back to main, close. Main minimizes on open and restores when the mini
is hidden or closed. Spacebar toggles, arrow keys skip tracks.
Cross-window sync: main window subscribes to playerStore and pushes
`mini:sync` via emitTo on track / play-state change. Audio progress
already broadcasts to all windows via `audio:progress`. Control actions
(prev/next/toggle) come back via `mini:control`; main-window restore
goes through a direct Rust command because WebKitGTK pauses JS in a
minimized webview.
Tiling-WM detection reused from existing `is_tiling_wm()` — always-on-top
is skipped on Hyprland/Sway/i3 since it's ignored there anyway.
Early alpha: no queue expand, no lyrics, no EQ, no drag-snap. Scope kept
deliberately tight for v1; follow-ups planned.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>