Commit Graph

875 Commits

Author SHA1 Message Date
Psychotoxical e8643c4059 refactor(audio): extract AutoEQ proxy commands into own module
Pull autoeq_entries + autoeq_fetch_profile out of audio/commands.rs
into audio/autoeq_commands.rs (~52 LOC). They proxy autoeq.app +
GitHub raw content via Rust to bypass WebView CORS — pure HTTP-fetch
flow with no playback state coupling, so they don't need to live next
to the playback orchestrator.

lib.rs invoke_handler updated to register them under
`audio::autoeq_commands::*`. audio/commands.rs: 1830 → 1784 LOC.
2026-05-08 13:35:43 +02:00
Psychotoxical 605021102f refactor(audio): peel device commands out of commands.rs
Hotspot J first slice: split the device-listing + device-selection
commands out of audio/commands.rs (1912 LOC monolith) into a new
audio/device_commands.rs (~95 LOC). Moved:
- audio_canonicalize_selected_device
- audio_list_devices_for_engine (kept pub for cli/exchange.rs callers)
- audio_list_devices
- audio_default_output_device_name
- audio_set_device

audio/mod.rs re-exports audio_default_output_device_name +
audio_list_devices_for_engine from the new submodule. The four
#[tauri::command] device handlers are registered in lib.rs run() under
their new path `audio::device_commands::*`.

audio/commands.rs goes from 1912 → 1830 LOC and drops its
`use super::dev_io::*` since playback/radio/EQ don't touch device
enumeration. Behaviour-preserving — same Tauri commands, same dev_io
helpers, same selected_device + stream_handle mutations.
2026-05-08 13:08:38 +02:00
Psychotoxical 3f46ab7c72 refactor(cli): extract Linux single-instance IPC into linux_forward submodule
Pull the Linux-only single-instance D-Bus stack out of cli/mod.rs into
a new cli/linux_forward.rs:
- tauri_identifier (reads identifier from embedded tauri.conf.json)
- single_instance_bus_name + single_instance_object_path (D-Bus path
  derivation matching tauri-plugin-single-instance)
- linux_bus_name_has_owner (zbus NameHasOwner wrapper)
- linux_is_primary_instance_running (pub)
- LinuxPlayerForwardResult enum (pub)
- linux_try_forward_player_cli_secondary (pub) — the heavy lifter:
  forwards argv via D-Bus ExecuteCallback, then for list/search
  commands reads the response file and prints to stdout

The whole submodule is `#[cfg(target_os = "linux")]` gated at the mod
declaration so non-Linux builds don't see it at all (cleaner than the
previous per-item gating).

Public surface preserved: lib.rs and main.rs keep importing
cli::linux_is_primary_instance_running and cli::LinuxPlayerForwardResult
unchanged via the linux-only pub use re-export.

cli/mod.rs: 696 → 550 LOC. Behaviour-preserving — same D-Bus protocol,
same response-file polling, same stdout output strings.
2026-05-08 13:00:10 +02:00
Psychotoxical cc60152762 refactor(cli): extract response-file IPC into exchange submodule
Pull the JSON response-file layer out of cli/mod.rs into a new
cli/exchange.rs:
- cli_*_path helpers (snapshot, library, server, search, audio_device)
  — XDG_RUNTIME_DIR-aware path resolvers
- write_cli_snapshot
- write_library_cli_response, write_server_list_cli_response,
  write_search_cli_response, write_audio_device_cli_response
- read_*_cli_response_blocking pollers (private to cli)
- print_*_cli_stdout wrappers (private to cli; thin "JSON or human"
  switches around the presenter functions)

Externally-visible names (cli::write_cli_snapshot, the four
write_*_cli_response, the path helpers) preserved via `pub use
exchange::*` re-export at the cli root. Internal-only functions
demoted to `pub(super)`.

cli/mod.rs: 895 → 696 LOC. Behaviour-preserving — same file paths,
same atomic write-via-tempfile pattern, same poll intervals + ready
predicates.
2026-05-08 12:48:49 +02:00
Psychotoxical 89fa1217b3 refactor(cli): extract human-format presenters into own submodule
Pull the JSON→stdout formatter layer out of cli/mod.rs into a new
cli/presenters.rs:
- print_library_human, print_server_list_human, print_search_human
- print_info_human + its helpers sorted_kv, value_inline
- print_audio_devices_human (kept as `pub` and re-exported from mod.rs)

Each takes a `serde_json::Value` and writes to stdout. No mutation,
no IO besides println, no inward calls back into mod.rs. cli/mod.rs
imports them via `use presenters::{...}` so the existing call sites
(print_*_cli_stdout wrappers, run_info_and_exit) read unchanged.

cli/mod.rs: 1151 → 895 LOC. Behaviour-preserving — same output
strings, same column separators, same scope handling for search.
2026-05-08 12:39:52 +02:00
Psychotoxical fff78c8f22 refactor(cli): convert cli.rs to module dir; extract parse layer
Convert the 1485-LOC cli.rs into a cli/ module directory and lift the
parse-only layer into its own submodule. cli/parse.rs now owns:
- the CliCommand / PlayerCliCmd / RepeatCliMode / SearchCliScope /
  MixCliMode enums
- the CliActionRegistry parser (reads shortcutActions.ts at startup)
- all wants_* flag helpers (--version, --info, --logs, --tail, --json,
  --quiet, --follow, logs_tail_lines)
- parse_cli_command + parse_player_cli_at + parse_repeat_mode

cli/mod.rs keeps the rest for now: print_*_human presenters, exchange
write_/read_ functions, run_info_and_exit, run_tail_and_exit, Linux
single-instance + IPC forwarding, describe_cli_command,
emit_player_cli_cmd, handle_cli_on_primary_instance. Those are the
next split candidates (presenters / exchange / linux_forward).

Public surface preserved via `pub use parse::*;` so main.rs and lib.rs
keep importing cli::wants_version, cli::parse_cli_command etc. unchanged.

cli/mod.rs goes from 1485 → 1151 LOC. Behaviour-preserving — same
parsing rules, same compile-time include of shortcutActions.ts, same
registry-driven verb resolution.
2026-05-08 12:30:30 +02:00
Psychotoxical 06f5c3e328 refactor(lib): extract tray state types into own module
Move tray-related state holders out of lib.rs into a new
src-tauri/src/tray_runtime.rs:
- TrayState / TrayTooltip type aliases
- TrayPlaybackState newtype
- TrayMenuItems / TrayMenuItemsState
- TrayMenuLabels (+ Default impl) / TrayMenuLabelsState
- tray_state_icon helper

The actual tray builder + Tauri commands (try_build_tray_icon,
set_tray_tooltip, set_tray_menu_labels, toggle_tray_icon) already
lived in lib_commands/sync/tray.rs and continue to reach the types
through the existing super::* re-export chain.

lib.rs goes from 549 → 486 LOC. Behaviour-preserving — same managed
state, same Tauri State<T> registrations in run().
2026-05-08 12:24:04 +02:00
Psychotoxical fd69cb4988 refactor(lib): extract analysis runtime queues into own module
Move ~440 LOC of analysis-queue plumbing out of lib.rs into a new
src-tauri/src/analysis_runtime.rs:
- AnalysisBackfillQueueState/Shared + worker loop + lazy-init
- AnalysisCpuSeedQueueState/Shared + worker loop + lazy-init
- submit_analysis_cpu_seed
- emit_analysis_queue_snapshot_line + analysis_queue_snapshot_loop
- WaveformUpdatedPayload (only used internally)

External callers keep their existing paths via `pub(crate) use
analysis_runtime::*` re-export at the lib.rs root. Direct accesses to
the static globals from app_api/analysis.rs are replaced with a new
prune_analysis_queues(keep) function so the statics stay private.

lib.rs goes from 999 → ~540 LOC. sync_cancel_flags stays put (sync
domain, separate concern). Tray-related types stay too — they're a
separate split candidate.

Behaviour-preserving: same workers, same queues, same events, same
return shapes. cargo check clean.
2026-05-08 12:20:40 +02:00
Psychotoxical d8f3014957 refactor(analysis_cache): unify Symphonia decode-setup boilerplate
Both count_mono_frames_from_audio_bytes and decode_scan_pcm started
with the same ~25 LOC of Symphonia setup (Cursor → MediaSourceStream →
probe → track-select with two fallbacks → codec_params → decoder).
Extract that into open_decode_session(bytes) returning a DecodeSession
struct (format + decoder + track_id + timeline_hint).

Behaviour delta: count_mono_frames_from_audio_bytes previously failed
silently if the decoder couldn't be built (.ok()?). Now it logs the
same `[analysis] decoder make failed: …` line that decode_scan_pcm
already emitted. Same return value (None), only an extra debug-print.

Net: -38 +32 in compute.rs. Both call sites now read as a single line
of setup followed by their loop.
2026-05-08 12:00:54 +02:00
Psychotoxical 635a59f133 refactor(lib_commands): centralise HTTP-stream-to-disk pattern in file_transfer
Pull the duplicated `subsonic UA + timeout client builder` and
`stream → .part → rename → cleanup` flow out of cache/offline.rs,
sync/device.rs, and sync/batch.rs into a new lib_commands/file_transfer
module.

- subsonic_http_client(timeout): standard UA + single timeout. Used by
  the 3 simple-timeout sites (offline track, sync_track, batch sync).
  download_update / download_zip / fetch_netease_lyrics keep their own
  builders since they need separate connect+overall timeouts or extra
  headers.
- stream_to_file(response, dest): chunked HTTP body → file (moved from
  cache/offline.rs, where it was the de-facto shared helper anyway).
- finalize_streamed_download(response, dest, part): stream to .part
  then rename, with best-effort cleanup of .part on any failure.

Behaviour delta: the offline + device single-track flows now also clean
up an orphan .part file when the final rename fails — previously only
the batch sync did this. Strictly safer; no observable change on the
success path.

downloads.rs (download_update / download_zip) keeps its inline progress
chunk loops — those emit per-chunk progress events with flow-specific
payload shapes and intervals; sharing them would force a callback API
that's heavier than the duplication it removes.

Net delta: -53 LOC across the 3 call sites.
2026-05-08 11:53:06 +02:00
Psychotoxical 66cbf25469 refactor(analysis_cache): split SQLite store from decode/EBU compute
Convert analysis_cache.rs (901 LOC) into a module directory:
- store.rs (408 LOC): AnalysisCache + types + SQLite plumbing
  (DB path, pragmas, schema migration, all DML, track-id variant lookup)
- compute.rs (498 LOC): Symphonia decode + EBU R.128 + waveform binning
  (seed_from_bytes_execute, analyze_loudness_and_waveform, decode_scan_pcm,
  byte-envelope fallback, recommended_gain_for_target)
- mod.rs: re-exports the unchanged public surface

External callers (lib.rs, audio/helpers, audio/commands, cache/offline,
app_api/analysis) keep importing from `crate::analysis_cache::*` — same
names, same signatures, behaviour unchanged. cargo check clean.
2026-05-08 11:47:27 +02:00
Psychotoxical 480e32ba04 refactor(cache): extract dir_size + prune_empty_dirs into fs_utils
Lift the duplicated `dir_size` walker and the empty-directory upward-prune
loop out of cache/offline.rs and cache/hot.rs into a shared
cache/fs_utils.rs module. Behaviour-preserving: same boundary semantics,
same skip-on-error policy, same `remove_dir`-only pruning (never
remove_dir_all).

Net delta: -76 LOC across cache/, single source of truth for cache cleanup.
2026-05-08 11:40:37 +02:00
Frank Stellmacher 8b781a848d refactor(i18n): show language names as endonyms in picker (#514)
* refactor(i18n): show language names as endonyms in picker

* docs(changelog): add language-endonyms entry for #514
2026-05-08 00:19:35 +02:00
Frank Stellmacher c982362884 fix(i18n): OpenDyslexic font supports Cyrillic (#513)
* fix(i18n): OpenDyslexic font supports Cyrillic

The OpenDyslexic subtitle in the font picker stated "no RU/ZH support",
but the bundled `@fontsource/opendyslexic` 5.x ships Cyrillic glyphs and
Russian renders correctly — verified empirically. Only Chinese (CJK)
actually falls back to the system font. Updated the subtitle string in
all 8 locales accordingly; the Russian locale itself no longer claims
unsupported-self.

* docs(changelog): correct OpenDyslexic locale-coverage claim (#513)

The #507 entry in the unreleased v1.46.0 block claimed `Latin +
Latin-extended only` and listed Cyrillic among the system-font
fallbacks. Cyrillic glyphs ship in @fontsource/opendyslexic 5.x and
render correctly — only Chinese (CJK) actually falls back. Updated the
bullet in place to match the corrected subtitle hints from this PR.
2026-05-07 23:55:24 +02:00
Frank Stellmacher 6f50fb6a19 feat(player-bar): album context menu on song title right-click (#512)
* feat(player-bar): album context menu on song title right-click

Right-clicking the track title in the player bar now opens the same
album context menu that album cards use (open, play next, enqueue,
go to artist, favorite, rate, share, download, add to playlist).

Mirrors the existing left-click behavior on the title, which already
navigates to the album. Suppressed for radio and preview, matching
the click handler.

MarqueeText gains an optional onContextMenu prop; PlayerBar builds a
SubsonicAlbum shape from currentTrack on demand.

* docs(changelog): add entry for PR #512 (player-bar title context menu)
2026-05-07 23:33:25 +02:00
Frank Stellmacher dc2068303d chore(deps): bump Tauri 2.11.0 → 2.11.1 (GHSA-7gmj-67g7-phm9) (#509)
* chore(deps): bump Tauri 2.11.0 → 2.11.1 (GHSA-7gmj-67g7-phm9)

Patches the IPC origin-confusion advisory: Tauri 2.11.0 and below could
let a remote-origin page loaded inside the webview invoke local-only
IPC commands. Severity medium. Psysonic exposes a number of file-system
and credential-bearing IPC commands (download_zip, nd_get_song_path,
audio_*), so closing the gate is worth the lockfile-only bump.

Cargo.toml is unlocked at "2", so this is purely a Cargo.lock refresh
via `cargo update -p tauri --precise 2.11.1`. Full Tauri family bumped
together (tauri / tauri-build / tauri-codegen / tauri-macros /
tauri-runtime / tauri-runtime-wry / tauri-utils — all matching patch
releases). wry, tao and other transitive deps unchanged. phf 0.11.3
fell out of the dependency graph because the new tauri-utils no longer
pulls it.

Tested locally (Windows): tray hide/restore + single-instance second
launch + mini-player toggle + window-state persistence — all working.

* docs: changelog entry for PR #509

Logs the Tauri 2.11.1 GHSA security bump in v1.46.0 "## Fixed".
2026-05-07 22:43:15 +02:00
Frank Stellmacher 57fe847d71 refactor(settings): collapse all sections, drop font dropdown, surface OpenDyslexic (#508)
* refactor(settings): collapse all sections, drop font dropdown, surface OpenDyslexic

Settings opened on a tab where four or five sub-sections were expanded
on first render — audio device, theme list, lyrics sources, sidebar
customizer, random-mix copy, offline dir, language picker, keybindings
table. The page felt like a wall of controls before the user had even
looked for something specific. Removed every `defaultOpen` flag from
the SettingsSubSection call sites so each tab now boots with only the
section headers visible. Component default was already `false`.

ThemePicker auto-expanded the group containing the active theme on
mount. Same noise on a screen that already has the longest accordion
list in the app, and the blue dot in the group header already tells
the user which group holds the active theme. Initial open-group is
now `null` — all groups collapsed until the user clicks one.

Font picker had a dropdown-style button that toggled a list inside
the sub-section, which meant two clicks (open the section, then open
the dropdown) for what should be a one-click choice. Removed the
button + the `fontPickerOpen` state — opening the Font sub-section
now reveals the full list directly and a click sets the font without
collapsing anything. OpenDyslexic moved to the top of the list so
users with dyslexia don't scroll past 14 sans-serifs to find their
option; the rest stays in the original order.

* docs: changelog entry for PR #508

Logs the Settings collapse-by-default + font picker cleanup +
OpenDyslexic ordering in v1.46.0 "## Changed".
2026-05-07 22:31:50 +02:00
Frank Stellmacher f520f7951a feat(settings): OpenDyslexic font option for dyslexic readers (#507)
* feat(settings): OpenDyslexic font option for dyslexic readers

Next step on the accessibility track. The first pass was on the colour
side — WCAG contrast audits across every theme and dedicated colour-
vision-deficiency variants for the protanopia / deuteranopia / tritan-
opia palettes. Typography is the other axis: some users with dyslexia
find a font with a heavier weighted baseline and asymmetric glyph
shapes (b/d, p/q never mirror, italic forms differentiated rather
than slanted-regular) easier to track than a typical sans.

Adds OpenDyslexic to the existing Fontsource font picker. SIL OFL
licensed, freely redistributable, and the de-facto open-source
standard for this use case. Non-variable axis, ships as four discrete
weight/style files (regular, bold, italic, bold-italic) — the Settings
picker grew an optional `hint` field on font entries so this one row
can carry a "dyslexia-friendly · no RU/ZH support" subtitle without
bloating the other 14 entries.

Latin + Latin-extended only. Cyrillic and CJK locales (RU, ZH) fall
back to the system font when this is selected; the subtitle calls out
that limitation upfront. i18n: hint string in all 8 locales
(settings.fontHintOpenDyslexic).

Accessibility is intentional product positioning here — it's an
underserved corner of the Subsonic-client ecosystem.

* chore(nix): sync npmDepsHash with package-lock.json

* docs: changelog entry for PR #507

Logs the OpenDyslexic font option in v1.46.0 "## Added".

* docs(settings): contributor entry for PR #507

Adds the OpenDyslexic accessibility bullet to Psychotoxical's
contributions list.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-07 22:15:38 +02:00
Frank Stellmacher bd56177e2c feat(home): Lossless Albums rail + dedicated page + sidebar nav (#506)
* wip(home): Lossless rail + dedicated /lossless-albums page

Rail under Home > mostPlayed and a dedicated infinite-scroll page that
list albums whose tracks are tagged in lossless containers. Walks
Navidrome's native /api/song?_sort=bit_depth&_order=DESC, dedupes by
albumId on the way down, stops when the song stream crosses into lossy
(bitDepth==0) or the server runs out of rows. _filters has no operators
on quality columns, so a sort + walk is the only path; equality on
sample_rate / bit_depth probes returned empty (verified).

The page paginates through the song-cursor with an in-flight ref so
overlapping IntersectionObserver fires don't double-add albums, plus a
cancelled flag so React StrictMode's double-mount doesn't apply two
parallel result sets in dev.

Suffix allowlist excludes ambiguous wrappers — m4a/m4b can be ALAC
*or* AAC and Navidrome's response carries an empty codec field, so we
can't tell them apart; same story for wma. Allowlist is flac, wav,
aiff/aif, dsf/dff, ape, wv, shn, tta — containers that are only
lossless. ALAC-in-m4a setups will miss out, acceptable trade-off
without a reliable codec field.

Status: WIP. Settings home-customizer label and en.ts strings landed,
no other locales yet, no quality badge on AlbumCards across the rest of
the app, no CHANGELOG. Branch was 'feat/home-hires-rail' during the
earlier hi-res-only iteration before the lossless broadening.

* wip(home): Lossless page header parity + streaming + sidebar nav

Page header now mirrors All Albums: selection mode with three action
buttons (Enqueue Selected, Add Offline, Download ZIPs) wired to the
same handlers Albums.tsx uses, selection-counter title swap, and the
perfFlags.disableMainstageStickyHeader respect path. Filters were
intentionally skipped — the rail is sorted by bit_depth, mixing client-
side filters with server-driven pagination would produce gaps.

Loading feels noticeably faster: ndListLosslessAlbumsPage takes an
optional onProgress callback that fires once per internal fetch with
the entries discovered in that fetch, so the page can stream new
albums into state instead of waiting on the whole loadMore. Page-side
budget dropped from 5×200 to 2×100 songs per loadMore (~1 MB worst
case vs 5 MB before), since the rail's catch-em-all pass is wrong for
infinite-scroll UX. Subtitle under the page title primes the user that
this is slower than other album pages because Psysonic walks the song
catalog by quality (Navidrome ignores _fields, so per-song responses
ship with lyrics + tags + participants whether we want them or not).

Sidebar nav entry registered under 'losslessAlbums' with a Gem icon,
defaults to visible:false (matches composers / folderBrowser /
deviceSync — niche browsing modes). Existing users get the entry
appended at the end of their persisted sidebar list automatically via
the onRehydrateStorage merge that sidebarStore already runs for new
DEFAULT_SIDEBAR_ITEMS.

i18n: full coverage across all 8 locales for sidebar.losslessAlbums,
home.losslessAlbums, losslessAlbums.empty, losslessAlbums.unsupported
and the new losslessAlbums.slowFetchHint subtitle. ru/zh are
machine-translation quality, flagged for a polish pass.

* feat(home): default Lossless sidebar entry to visible

Flips the DEFAULT_SIDEBAR_ITEMS entry for `losslessAlbums` from false
to true. Existing installs keep whatever the user has in persisted
storage; fresh installs see the entry in the sidebar from the start.

Earlier wip commit defaulted it off (matched composers / folderBrowser
/ deviceSync as a niche browse mode), but the rail + page do show
something useful for any library with at least one FLAC/WAV/etc. album,
so off-by-default just hid the feature.

* docs: changelog entry for PR #506

Logs the Lossless Albums rail + page + sidebar entry in v1.46.0
"## Added".

* docs(settings): contributor entry for PR #506

Adds the Lossless Albums rail/page bullet to Psychotoxical's
contributions list.
2026-05-07 20:44:27 +02:00
Frank Stellmacher a41e3a624a feat(song-info): show absolute file path on Navidrome via native API (#504)
* feat(song-info): show absolute file path on Navidrome via native API

Subsonic's `getSong.view` returns at most a relative path (or none on
Navidrome), so the Path row in the Song Info modal stayed empty for
most users. Feishin and the Navidrome web client surface the full
server-side path by hitting Navidrome's native `/api/song/{id}` instead.

Added an `nd_get_song_path` Tauri command that logs in to the native
API with the active server's credentials, fetches the song, and returns
the `path` string. Wired it into `SongInfoModal`: the Subsonic
`getSong` call still drives the rest of the dialog, and the native call
runs in parallel only when the active server's identity is
"navidrome". When it returns a path, that absolute path replaces the
relative Subsonic value; native-API failures are silent and the modal
falls back to whatever Subsonic provided.

No token cache yet — the modal is opened occasionally enough that one
fresh login per call is fine.

Closes discussion #479.

* docs: changelog entry for PR #504

Logs the Navidrome native-API absolute file path support for the
Song Info dialog in v1.46.0 "## Added".

* docs: settings contributor entry for PR #504, drop competitor mention

Adds the song-info absolute path bullet to Psychotoxical's contributors
list in Settings, and rewords the existing CHANGELOG entry so neither
text references competing clients.
2026-05-07 20:22:55 +02:00
Frank Stellmacher 726f3f0ff2 fix(radio): queue navigation, dedup, and similar-first variety (#500) (#503)
* fix(radio): queue navigation, dedup, and similar-first variety (#500)

After a Radio session ran a while, three things broke:

Queue navigation through duplicates. playTrack re-resolved the active
queue index by `findIndex(t.id === track.id)`, returning the *first*
matching id, so reaching the second occurrence of a track snapped
queueIndex back to the earlier slot — highlight jumped and the next
advance played the wrong follow-up. Added an optional
`targetQueueIndex` to playTrack, threaded through next(), previous(),
the audio:ended repeat-one path, queue-row click, and the queue-item
context menu. findIndex stays as the fallback for callers that just
have a track and a fresh queue.

Queue accumulation. enqueueRadio didn't dedupe incoming tracks; the
next() top-up deduped against the live queue but trimmed the played
tail down to HISTORY_KEEP=5, so a song heard 8 ago was gone from
`existingIds` and a later Last.fm/topSongs response could re-add it;
and the `.filter(...)` pass admitted intra-batch repeats (top +
similar overlap is common) because it read the dedup set before
mutating it. A module-level radioSessionSeenIds set, fed by
enqueueRadio and both top-up paths and reset on artist change and
clearQueue, closes all three: trimmed ids stay in the set, ids about
to be replaced (fresh enqueueRadio wiping the pending radio block)
are removed first so callers can re-introduce them, and the dedup
pass mutates the set inline.

Variety. Starting Radio on a track stacked five top tracks of the
seed artist before any similar-artist material played. Switched the
seed path and both top-up paths to lead with similar songs (other
artists) and only fall back to top tracks when similar comes back
empty — preserves the "no Last.fm" graceful degradation but stops
the seed artist from monopolising the front of the queue.

Not affected: gapless audio:track_switched (already index-based, no
findIndex), AudioMuse Instant Mix / Lucky Mix (single-element queues
or enqueue-only paths), the artist-radio path (no seedTrack — already
picks just one top track and fills the rest from similar).

Reported by netherguy4.

* docs: changelog entry for PR #503

Logs the radio queue navigation/dedup/similar-first fix in v1.46.0
"## Fixed".
2026-05-07 19:56:57 +02:00
Frank Stellmacher d7ff1d3113 fix(preview): keep preview sink volume in sync with player slider (#498) (#502)
* fix(preview): keep preview sink volume in sync with player slider (#498)

The Rust preview sink had its volume set once at audio_preview_play and
then never updated. audio_set_volume only ramps the main sink, so slider
movements during a preview had zero effect on the preview level. With
the default loudness normalization (-4.5 dB pre-analysis attenuation)
applied at start, even a 100% slider gives 1.0 × 0.596 × MASTER_HEADROOM
≈ 53% — matching the user-visible "fixed at around 50%" symptom.

- Add audio_preview_set_volume Rust command that updates the preview
  sink if one is active (clamp + master headroom mirror the path used
  in audio_preview_play).
- Extract the preview-volume calculation in previewStore into
  computePreviewVolume() so startPreview and the new sync path share
  one formula (slider value, plus the LUFS pre-analysis attenuation
  the engine already applies to the main sink).
- Subscribe to playerStore at module level: when volume changes and a
  preview is active, push the recomputed value to Rust. Auth /
  normalization tweaks during preview are intentionally not synced —
  preview is short and that case is rare.

Reported by netherguy4.

* docs: changelog entry for PR #502

Logs the preview-volume-slider sync fix in v1.46.0 "## Fixed".
2026-05-07 19:10:18 +02:00
Frank Stellmacher 3cabb64dbc fix(tray): resume rendering on second-launch restore (#497) (#501)
* fix(tray): resume rendering on second-launch restore (#497)

The single-instance plugin callback that handles a second launch (e.g.
via desktop / start-menu shortcut while the main window is hidden in
the tray) was missing the RESUME_RENDERING_JS injection that the tray-
icon restore path already does.

When the main window is closed to the tray, PAUSE_RENDERING_JS sets
window.__psyHidden = true, marks <html data-psy-native-hidden="true">,
and zeroes --psy-anim-speed. A global CSS rule then pauses every
animation under <html>. Page wrappers across the app use
.animate-fade-in (animation: fadeIn ... both) which starts at
opacity: 0 — so when a route mounts after navigation the new wrapper
stays frozen at opacity: 0 and the page looks blank.

Restoring via the tray icon worked because that path injects
RESUME_RENDERING_JS before show(); only the second-launch path was
missing it. Mirror the same eval here so both restore paths are
consistent.

Reported by netherguy4.

* docs: changelog entry for PR #501

Logs the tray second-launch restore-rendering fix in v1.46.0 "## Fixed".
2026-05-07 18:59:55 +02:00
Frank Stellmacher 9c9e4dc438 Add files via upload (#496) 2026-05-07 13:11:22 +02:00
Frank Stellmacher ddb1f29af9 refactor(settings): remove redundant Animations 3-state setting (#495)
* refactor(settings): remove redundant Animations 3-state setting under Seekbar Style

The `animationMode` setting (Full / Reduced / Static) duplicated work
the perf-flag system and OS-level reduced-motion preference already
covered:

- `perfFlags.disableMarqueeScroll` already kills marquee scrolling on
  demand, replacing what `static` mode used to gate.
- The `data-perf-disable-animations` html-level switch already strips
  every `*` animation, replacing what `static` mode used to do globally.
- `@media (prefers-reduced-motion: reduce)` honours the OS setting for
  every user that asked for it via system preferences.
- The 30 fps cap that `reduced` mode applied to the seekbar wave was
  better served by per-feature perf toggles cucadmuh added later.

Removed:
- `AnimationMode` type, `animationMode` field + setter from auth store.
- Settings UI block (3 buttons + hint text) under Appearance > Seekbar
  Style.
- `animationMode === 'static'` short-circuit in WaveformSeek's rAF
  effect; `isReduced` skip-every-other-frame logic; `static`-checks in
  `drawNow` / `needsDirectDraw`.
- `animationMode !== 'static'` guard and `data-anim-mode` attribute in
  MarqueeText.
- `[data-anim-mode="static"]` and `[data-anim-mode="reduced"]` rules in
  layout.css.
- Seven i18n keys (animationMode + 6 variants) across all eight
  locales.

Migration: the persist layer strips `animationMode` (and the legacy
`reducedAnimations` boolean predecessor) so anyone who had `'reduced'`
or `'static'` selected silently lands on the former `'full'` path on
first launch after upgrade. No user-facing prompt — the missing setting
just stops existing.

cucadmuh's PR #472 (FPS overlay), #476 (preview-freeze main seekbar,
sleep-recovery hooks, card-hover removal) and #486 (interpolation
anchor reset on resume) are all preserved untouched — they live in
separate effects / files and were not driven by `animationMode`.

* docs(changelog): add Removed section for animationMode setting (PR #495)

* docs(changelog): refine animationMode removal rationale (drop prefers-reduced-motion overstatement)
2026-05-07 12:53:19 +02:00
Frank Stellmacher ba73649360 fix(home): more variety + reliable render in Because-you-listened rail (#494)
Two reports rolled into one pass:

1. Rail occasionally rendered nothing on Home open. Cause: the rotated
   anchor sometimes had no Last.fm similar-artists or no library matches
   among the sampled set, and the old code gave up after one try and
   stored that dud anchor as the rotation cursor — so the next mount
   started from the same dud's neighbour and could fail again.

2. Recommendations felt repetitive. Cause: pool of 12 anchors walked
   round-robin made each anchor recur every 12 mounts, similar-artist
   sample of 6 from 12 had heavy overlap visit-to-visit, and per-artist
   single-album random pick meant artists with one library album always
   surfaced the same record.

Anchor selection: random pick from pool with a per-server cooldown
buffer (last 5 anchors excluded, capped at floor(pool/2) so small
libraries don't soft-lock). Up to 4 anchors are tried in a shuffled
candidates list before giving up; the localStorage cursor only advances
on a successful anchor so duds don't poison future mounts.

Picks variety: similar-artist fetch raised from 12 to 25 (same
getArtistInfo call, larger response — Last.fm typically returns up to
~50). Per-server ring buffer of the last 30 shown album ids; per-similar
-artist album choice prefers an album not in that buffer, falling back
to any album when the artist's whole catalogue is stale so the slot is
never lost.

Pool cap raised 12 -> 20 to give the cooldown buffer room to breathe in
libraries with varied listening history.

Storage: legacy `psysonic_because_anchor:` single-id keys from the
round-robin era are stripped on module load (one-shot localStorage
sweep, the new `..._anchor_history:` prefix has a different colon
position so no false matches).

API budget unchanged in the hot path: 1 getArtistInfo + 6 getArtist
per Home mount. Worst case (3 dud anchors, 4th succeeds) is 4
getArtistInfo + 6 getArtist.
2026-05-07 10:40:12 +02:00
Frank Stellmacher d75670ec4b feat(home): broaden Because-you-like seed pool + tidy orphan card at 1080p (#493)
* feat(home): mix recently-played + starred into Because-you-like anchor pool

Anchor pool was sourced only from getAlbumList(frequent), so the rotation
cursor walked the same eight top-played artists no matter how varied the
rest of the listening history was. Round-robin merge of mostPlayed,
recentlyPlayed and starred (dedup by artistId) means each mount can land
on a different listening *mode* — heavy rotation, current focus, or
explicit favorites — instead of stepping through the same top-played
sequence.

Pool size 8 -> 12 to let the cursor visit all three modes before
wrapping. Visibility guard widened so the rail still renders when the
server has no frequent-play data yet but starred or recent items exist.
Zero new API calls — all three lists are already in Home's initial
fetch.

* fix(home): drop orphan 3rd Because-card in 2-col range, keep all 3 stacked on mobile

auto-fit grid wraps to 2 cols between 696-1051px container width, which
left the third card alone on a second row at 1080p. Container query
hides the 3rd card only inside that 2-col band; on wider screens the
full 3-up row stays, on narrow viewports (single column) all three
cards stack vertically as expected.

* docs(changelog): Because-you-listened seed pool + 1080p layout polish (PR #493)

* docs(changelog): fold PR #493 refinements into the existing Because-you-listened entry

Drop the separate Changed section entry — the feature is in the same
1.46.0 release window as PR #489, so readers want a single description
of the final behaviour, not "added X, then changed X" for the same
release. PR reference becomes "PRs #489, #493".
2026-05-07 09:12:47 +02:00
Frank Stellmacher b01e76df9c fix(home): Because-cards — readable layout at 1080p / 3-up (#492)
At 1080p with three cards per row (~370 px wide) the previous layout
broke down:

- 200×200 cover left only ~150 px of text width after gap + padding,
  so titles truncated mid-word and the meta-pill wrapped vertically
  into a stack instead of staying on one line.
- The "·" separators in the meta vanished as soon as the pill wrapped,
  because the ::after lives inside the wrapping flex line.
- Albums without cover-art rendered the placeholder as an empty grey
  rect — visually broken next to neighbours that did have art.

Adjustments:

- Cover wrap 200 → 160 px. Buys 40 px of text width per card and brings
  cover/text proportions into balance.
- Meta-pill: inline-flex with width: max-content + max-width: 100% so
  the pill is content-fit when the meta line fits, capped at the
  available text width otherwise. Font 12 → 10 px, column-gap and
  padding tightened, flex-wrap kept (wraps to a second row if a server
  ever returns a really long meta string instead of clipping).
- Cover-art placeholder shows a centred Lucide Music icon at 30 %
  --text-primary alpha — same visual weight as a faded thumbnail
  instead of an empty rect.
2026-05-07 02:58:01 +02:00
Frank Stellmacher 38b89f9730 fix(theme): migrate persisted state from removed theme ids (#491)
PR #490 dropped five community themes (amber-night, ice-blue, monochrome,
phosphor-green, rose-dark). Existing users who had any of those selected
land on a non-existent data-theme attribute after the update — the
browser silently falls back to :root defaults and the picker shows the
old id as inactive in the list.

Add a Zustand persist `migrate` hook (version 1) that remaps the removed
ids to the closest surviving palette per family — gold for amber, carbon
grey for ice / monochrome, deep forest for phosphor green, sakura night
for rose. Applies to `theme`, `themeDay` and `themeNight` (theme
scheduler), so a scheduled night theme that was set to a removed id is
remapped too.

New installs are unaffected (migrate runs against persisted state only).
2026-05-07 02:46:25 +02:00
Kveld. f82f1be63a feat: redesigned community themes (#490)
* redesigned community themes

* fixed select arrow obsidian-black & violet-haze

* docs: CHANGELOG + Contributors entry for community themes redesign (PR #490)

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-05-07 02:43:14 +02:00
Frank Stellmacher d1ff2fab51 feat(home): Because you listened recommendation rail (#489)
* feat(home): "Because you listened" recommendation rail

New Home rail (under Recently Added, default on, toggleable in Settings →
Personalisation → Home Page) that surfaces 3 albums from artists similar
to one of your top-played artists. Anchor rotates per Home mount so a
different top-artist seeds the recommendations each visit; within each
anchor, both the similar-artist subset and the chosen album per artist
are randomised, so the same anchor returns different picks on subsequent
visits.

Card layout matches the regular Album cards' surface (--bg-card with
accent-tinted border + 1px inset top highlight) and gets the same
Play / Enqueue hover overlay buttons. Cover and meta scale via CSS only
— no infinite animations, no filter/blur/transform, no compositing
layers. Grid wraps below 3-up at <400px card width instead of shrinking.

API budget: one getArtistInfo2 + 6 parallel getArtist calls per Home
mount, both reusing the existing mostPlayed payload to derive the anchor
pool (no extra API call to find top artists). All 8 locales seeded.

* fix(home): ru plurals + per-server anchor + narrower card grid

- ru: add _few / _many for becauseYouLikeTracks (CLDR Russian needs
  4 forms — 3 треков was wrong, now 3 трека).
- Anchor rotation memory is now per-server. The localStorage key
  becomes psysonic_because_anchor:<serverId>; switching servers no
  longer aliases server A's rotation onto server B's pool.
- because-card grid minmax(400px, 1fr) -> minmax(340px, 1fr) so two
  cards fit side by side at typical sidebar-expanded widths instead
  of collapsing to a single card per row.

* docs: CHANGELOG + Contributors entry for Because-you-listened rail (PR #489)

* style(home): blurred cover backdrop + centred layout for Because-cards

- Each Because-card renders the album cover as a blurred, low-opacity
  full-bleed background layer behind the existing cover thumb and text.
  Resolved through useCachedUrl so the cache layer feeds it (same key
  as the thumbnail) instead of a fresh salted URL on every render.
- Card content (cover thumb + text block) now centred horizontally and
  vertically within the card; the meta line lives in a small pill that
  sits centred under the artist row.
- Text contrast halo and meta-pill background are theme-aware via
  color-mix on var(--bg-card) / var(--text-primary), so the same rules
  read on dark and light themes (was hard-coded rgba black before and
  smudged the type on Latte / Nord Snowstorm).
2026-05-07 02:28:58 +02:00
Frank Stellmacher 5b37ab70f1 perf(tracklist): drop now-playing pulse + EQ-bar animations (#488)
* perf(tracklist): replace animated EQ-bar + active-row pulse with static icon

The currently-playing track in any tracklist (AlbumDetail, ArtistDetail,
PlaylistDetail, Favorites, RandomMix) was rendered with two animations
on top of an already-busy DOM:

- `.track-row.active` ran `track-pulse` 3s opacity 1 → 0.6 → 1 infinite
  on the entire row subtree (title button, several Lucide icons, star
  rating SVGs, hover affordances). Opacity is a compositor property, but
  on WebKitGTK without compositing — Linux + NVIDIA proprietary +
  WEBKIT_DISABLE_COMPOSITING_MODE=1 — every animated row falls back to a
  full software repaint of the subtree per frame.
- The "now playing" indicator in the track-number cell was three
  `<span class="eq-bar">` siblings with `transform: scaleY()` keyframes
  (`eq-bounce`), each on its own delay/duration. Same composite story:
  three software-repainted layers per frame, every frame.

On AlbumDetail (long tracklist + cover-art header background + the
already-running WaveformSeek progress rAF in the player bar) the
combined cost held the WebProcess at ~80 % CPU with 1.2 GB RSS for the
entire duration of playback. CPU dropped immediately on pause/stop;
on Composers / Settings (no track rows) the symptom never appeared.
Profiler confirmed continuous Layout & Rendering work synchronised with
isPlaying, not the canvas itself.

Replace both animations with static visuals:

- `.track-row.active` keeps the `--accent-dim` background, drops the
  pulse animation entirely.
- The "now playing" indicator becomes a single Lucide `AudioLines` icon
  (four vertical bars of different heights — reads as an EQ icon, no
  animation, one SVG per active row instead of three animated spans).
  `.eq-bars` className now only sets the accent colour.

Cleanup: dead `@keyframes track-pulse`, `@keyframes eq-bounce`,
`.eq-bars.paused` rule, plus a duplicate `.eq-bar` block in theme.css
(with a `wave` keyframe that was being shadowed by components.css and
had no other consumers).

No behaviour change beyond removing the animation; the row is still
visibly the active one and the icon still marks the playing track.

* docs: CHANGELOG entry for tracklist animation perf fix (PR #488)
2026-05-07 01:57:14 +02:00
Frank Stellmacher 59744601d4 feat(composer): Browse by Composer page (issue #465) (#487)
* feat(composer): Browse by Composer page (issue #465)

New library section listing every artist credited as composer on at
least one track, with a detail page showing all works they're credited
on in that role. Targeted at classical-music libraries where the
"recording artist" tag carries the orchestra and the "composer" tag
carries Bach / Mozart / Chopin.

Hits Navidrome's native /api/artist?_filters={"role":"composer"} for
the listing and /api/album?_filters={"role_composer_id":"…"} for the
works grid — Subsonic getArtist only follows AlbumArtist relations and
returns 0 albums for composer-only credits, so the native API is the
only path that works. Requires Navidrome 0.55+ (uses
library_artist.stats role aggregation); on older / pure-Subsonic
servers the page shows a one-line capability banner.

- Two new Tauri commands: nd_list_artists_by_role +
  nd_list_albums_by_artist_role, generic over participant role so
  conductor / lyricist / arranger pages are trivial to add later.
- Composers grid: text-only compact tiles (name + participation count
  pulled from stats[role].albumCount). No avatars — composer libraries
  carry no useful imagery and the listing endpoint exposes no image
  URLs anyway.
- ComposerDetail: hero with Last.fm bio (via getArtistInfo2) plus the
  full work grid, with a graceful fallback when the artist has no
  external info synced.
- Sidebar entry default off (Feather icon) — opt-in for the niche
  classical use case.
- nd_retry backoffs widened from [500] to [300, 800, 1800] — helps
  every nd_* call survive intermittent TLS-handshake-EOF errors that
  some reverse-proxy setups produce when keep-alive pools churn.
- Distinguishes "server can't do this" (HTTP 400/404/422/501) from
  transient errors so the capability banner only fires when the server
  actually rejects the request shape; everything else gets a retry
  button.
- i18n in all 8 supported locales.

* fix(composer): address review feedback on detail page + role queries

- Re-fetch ComposerDetail when music-library scope changes; previously
  the album grid stayed stale until navigation while the list refreshed.
- Thread library_id through nd_list_artists_by_role and
  nd_list_albums_by_artist_role so role queries respect the active
  Navidrome library, matching the Subsonic musicFolderId already piped
  through libraryFilterParams().
- Fix CachedImage cache-key mismatch on ComposerDetail: a Last.fm header
  image was stored under the Subsonic cover-art key, aliasing cache
  entries and risking cross-source pollution.
- Consolidate the two contradictory composer-imagery comments in
  Composers.tsx into a single accurate one (the older one referenced an
  Images toggle that was never implemented).
- Align openLink toast duration with ArtistDetail (1500ms -> 2500ms).

* fix(composer): keep bio across scope changes, add share, degrade gracefully

Three remaining items from the latest review pass on the composer flow.

1. Bio survives a music-library scope change.
   The previous fix added musicLibraryFilterVersion to the load effect,
   but that effect also did setInfo(null) while the getArtistInfo effect
   still depended on [id] alone — so a scope bump on the open page
   wiped the bio without re-fetching it. Move the info reset into the
   bio effect (keyed on id) and out of the load effect: the album grid
   still refreshes on scope change; the Last.fm header image and
   biography survive untouched, since both are library-independent.

2. Composers join the share pipeline as a first-class entity kind.
   Extend EntityShareKind with 'composer' (and isEntityKind), branch
   applySharePastePayload to validate via getArtist (same id pool) and
   navigate to /composer/:id, and wire a Share button into
   ComposerDetail. A pasted composer link now opens the composer view
   instead of the artist view, matching what was copied. i18n added in
   all 8 locales (sharePaste.composerUnavailable, openedComposer;
   composerDetail.shareComposer, unknownComposer).

3. Partial server failure no longer hides the works.
   If getArtist rejects but ndListAlbumsByArtistRole succeeds, the page
   used to show full "not found" despite having data to display. Switch
   the not-found gate to require both empty (`!artist && !albums`) and
   render a degraded header (placeholder name, no Wikipedia / favourite
   / share / Last.fm image) when only metadata is missing.

* fix(composer): right-click share copies a composer link, not an artist link

The context menu opened from a composer card / row uses type='artist'
because every composer-action (radio, favourite, rating, add-to-playlist)
is identical to the artist counterpart — they share an id space and a
backend representation. Sharing was the one exception: the "Share Link"
entry produced a 'psysonic2-' string with k='artist', so a paste opened
/artist/:id even though the user came from /composers.

Add an optional shareKindOverride to openContextMenu (default: undefined,
preserves existing behaviour) and have the artist-typed branch consult
it when calling copyShareLink. Composers.tsx now passes 'composer' on
both right-click sites; nothing else changes downstream because the
override only affects the share kind.

* polish(composer): show Last.fm avatar even without server metadata

Two minor follow-ups from the latest review.

- ComposerDetail: drop the `&& artist` guard on the header-avatar render
  path. info?.largeImageUrl can resolve through getArtistInfo(id) without
  ever needing the SubsonicArtist record, so the previous gate hid a
  perfectly good Last.fm portrait whenever getArtist failed but the
  bio fetch succeeded. Replace artist.name with displayName so the
  alt / aria-label degrade to the localised "Composer" placeholder
  instead of empty strings.
- copyEntityShareLink: doc comment now mentions composer alongside
  track / album / artist.

* fix(composer): derive Last.fm cache key from route id, not from artist record

Follow-up to the previous polish: the avatar render path no longer
requires `artist` to be populated, but the cache-key gate still did. So
when getArtist failed but getArtistInfo returned a Last.fm portrait, the
key fell through to coverKey — which is empty without an artist record,
re-creating the very aliasing bug the earlier Subsonic-vs-Last.fm fix
was meant to close.

Switch the Last.fm branch to the route id (same id namespace as the
SubsonicArtist record), so the key stays stable whenever Last.fm art is
shown, independent of getArtist succeeding.

* docs: CHANGELOG + Contributors entry for composer browsing (PR #487)
2026-05-07 00:36:09 +02:00
cucadmuh c83447ebd2 fix(player): reset WaveformSeek interpolation anchor on resume (#486)
The interpolation effect unmounts while paused, so progressAnchorRef.atMs was
never refreshed. The first tick after play added the entire pause duration to
elapsedSec and overshot the playhead until the next transport heartbeat.
Re-anchor from getPlaybackProgressSnapshot() when the effect starts.
2026-05-06 21:48:06 +00:00
Frank Stellmacher e215694301 feat(help): rewrite Help page — trimmed Q/A, 10 sections, live search (#485)
* feat(help): rewrite English Q/A entries — trim, consolidate, refresh

The Help page had grown to ~50 entries over time, with several that
the UI itself answers (double-click to play, click the cover for
fullscreen, click the repeat button to cycle, …) and other groups
that were better folded into a single answer (rating + Skip-to-1★,
Internet Radio basics + supported formats, Device Sync overview +
filename template + cross-platform behaviour, …).

This pass:
- drops obviously-redundant entries (q4, q7, q8, q11, q22, q24, q25,
  and the trivial Settings → X pointers q12, q13, q15, q32, q42, q43)
- consolidates the natural groupings (q5+q31, q37+q38+q39, q53+q54+q55,
  q34+q35+q47, q26+q27+q28, q12+q41, q56+q57)
- adds entries for features that did not exist yet when the previous
  Q/A list was written: Orbit (Listen Together), Magic Strings
  sharing, LUFS Smart Loudness Normalization, Mini Player + Floating
  Player Bar, Smart Playlists, Track Preview, Search and Advanced
  Search, Statistics, Tracks library hub, Genre tag-cloud browser,
  Discord Rich Presence, Bandsintown tour dates, Multi-select +
  Shift-click range selection, Sidebar / Home / Artist Page
  customization, Sleep Timer, Open Source Licenses

Result: 45 focused entries across 10 sections (Getting Started /
Playback & Queue / Audio Tools / Library & Discovery / Lyrics /
Sharing & Social / Personalization / Power User / Offline & Sync /
Integrations & Troubleshooting), each one answering something the UI
does not already answer at a glance.

* feat(help): restructure into 10 sections with live search

Page is now organised into ten focused sections (Getting Started,
Playback & Queue, Audio Tools, Library & Discovery, Lyrics, Sharing
& Social, Personalization, Power User, Offline & Sync, Integrations
& Troubleshooting) each rendered as its own column-friendly accordion
group with a Lucide icon.

A search input lives in the page header. Typing filters every Q+A
pair across all sections by case-insensitive substring; sections that
end up empty are hidden, matched items are auto-expanded so the user
sees the answer without having to click each result, and a "no
results" empty state appears when the query matches nothing. Clearing
the input restores the manual accordion behaviour. An × button next
to the input clears the query in one click.

CSS uses dedicated `.help-search`, `.help-search-icon`,
`.help-search-input`, `.help-search-clear` rules instead of leaning
on the global `.input` class — the latter brought its own focus-ring
styles that doubled with the wrapper border. Focus state highlights
the wrapper border to `--accent` via `:focus-within`.

* i18n(help): translate the new Help page to 7 locales

Updates de, fr, nl, zh, nb, ru, es to match the new English Q/A
structure (45 entries across 10 sections, plus the live-search
labels: title, searchPlaceholder, noResults).

DE / FR / NL / NB / ES were translated directly. RU and ZH are
structurally correct but written at machine-translation quality;
both could use a pass from the original locale maintainers
(@cucadmuh for RU, @jiezhuo for ZH) — none of the wording is
load-bearing for the i18n keys, so the page renders correctly today
and refinements can land as follow-up touch-ups without coupling.

* docs: changelog + contributors for PR #485

Adds the v1.46.0 "Changed" entry and the Psychotoxical contributors
line for the Help page rewrite.
2026-05-06 19:28:47 +02:00
Frank Stellmacher 43d75e744b feat(selection): Shift+Click range selection on grid pages (#484)
* feat(selection): add useRangeSelection hook with Shift+Click range support

Reusable hook for multi-select state across pages that show grids of
items the user can pick. Tracks the selected ID set, a click anchor,
and exposes a `toggleSelect(id, { shiftKey })` callback:

- Plain click → toggles that item and moves the anchor to it.
- Shift-click on a second item → adds every item between the anchor
  and the click target (inclusive) to the selection. The anchor moves
  to the shift-clicked item so the next shift-click extends from
  there.

Range expansion follows the items array passed to the hook, so the
caller controls the user-visible order (filtered + sorted list, not
the raw upstream array).

Implementation note: the anchor ref is snapshotted *before* the state
updater runs and written *after* it. React 18 strict mode invokes
state updater functions twice in dev to surface side effects, so any
ref mutation inside the updater would taint the second invocation and
the replay would miss the range branch.

* feat(selection): adopt Shift+Click range selection on grid pages

Wires `useRangeSelection` into the four pages that ship a multi-select
mode on top of card grids:

- Albums (passes the filtered/sorted `visibleAlbums` so range follows
  the order the user actually sees)
- RandomAlbums
- NewReleases
- Playlists

`AlbumCard.onToggleSelect` is extended to forward `{ shiftKey }`, and
the card's onClick handler reads `e.shiftKey` from the React event
and threads it through. The Playlists grid uses an inline onClick on
the card div and was updated the same way.

User-visible behaviour: in selection mode, click an item then
shift-click a later item — every item between them gets selected.
Existing single-toggle behaviour is unchanged when no shift key is
held.

* docs: changelog entry for PR #484

Logs the Shift+Click range selection on grid pages under
v1.46.0 "## Changed".
2026-05-06 17:51:25 +02:00
Frank Stellmacher 6c1deeeb7f feat(most-played): quick actions, real context menu, prominent plays badge (#482)
* feat(most-played): quick actions, real context menu, prominent plays badge

Three UX refinements on Settings → Most Played, in response to user
feedback:

* **Quick actions on each album row** — Play and Enqueue buttons that
  reuse the same logic as AlbumCard (Play kicks the existing
  `playAlbum` fade-out flow; Enqueue fetches the album and appends its
  songs to the queue). Always visible, not hover-gated.
* **Real context menu** on right-click — replaces a hidden direct
  `playAlbum` action with the standard `openContextMenu(...)` flow
  used elsewhere in the app, so right-click on an album row now opens
  the full album context menu (Play / Add to queue / Play next /
  Add to playlist / Go to artist), and right-click on a Top Artists
  card opens the artist context menu.
* **Plays badge next to the album title** — replaces the small
  right-aligned plays count that was easy to miss. Each row now shows
  a localized pill (`11 plays` / `11× gespielt`) right next to the
  album title, since the play count is the central datum on this
  page.

CSS: new `.mp-album-name-row`, `.mp-album-plays-pill`,
`.mp-album-actions` and `.mp-album-action-btn` rules; the unused
`.mp-album-plays` block and its right-most grid column were removed.

* docs: changelog entry for PR #482

Logs the Most Played quick-actions / real context menu / prominent
plays badge changes under v1.46.0 "## Changed".
2026-05-06 16:55:33 +02:00
Frank Stellmacher ebce53f8a7 fix(sidebar): centre Playlists icon and unify hover hitbox in collapsed mode (#481)
* fix(sidebar): centre Playlists icon and unify hover hitbox in collapsed mode

The Playlists nav entry had its own special render path with a wrapper
div, header-row and `flex: 1` main link to fit the expand-toggle
button. Those elements remained active in collapsed mode too, where
`padding-right` on the header-row and `flex: 1` on the link made the
icon sit off-centre and gave the row a wider hover hitbox than every
other collapsed sidebar item.

Hoist the `isCollapsed` check above the playlists special-case so that
in collapsed mode Playlists renders through the same plain `<NavLink>`
branch as Artists / Albums / Favorites / etc. The expanded-mode
treatment (wrapper, header-row, expand-toggle, nested playlist list)
is unchanged.

* docs: changelog entry for PR #481

Logs the collapsed-sidebar Playlists icon centring fix in v1.46.0
"## Fixed".
2026-05-06 16:12:50 +02:00
cucadmuh b084e96c1f fix: prune stale analysis queues and cap loudness backfill window (#480)
* fix(analysis): prune stale backfill jobs and limit prefetch window

Drop pending backfill and cpu-seed jobs that are no longer in the active playback queue, and add debug counters for pruned work. Limit loudness backfill scheduling to the current track plus the next five tracks to prevent runaway queue growth in dev sessions.

* chore(analysis): remove unused loudness prefetch parameter

Drop the now-unused incoming-tracks parameter from the loudness prefetch helper and update internal call sites to match the current queue-window scheduling logic.

* docs(changelog): document analysis queue control fix (#480)

Add a short 1.46.0 Fixed entry describing stale backfill pruning, the current+5 loudness backfill window cap, and debug prune counters for diagnostics.

* docs(contributors): add cucadmuh entry for PR #480

Logs the analysis-queue prune + loudness backfill window cap in the
Settings → System → Contributors list.
2026-05-06 16:42:48 +03:00
Sayykii dc35f53674 feat(artist): group albums by release type on artist page (#471)
* feat(artist): group albums by release type on artist page

Uses the releaseType field to group albums/releases into sections like Albums, Compilation, Live, etc.
If there's no release type it falls back to normal view

* feat(artist): i18n release-type group labels

* fix(artist): deterministic release-type group order

* refactor(artist): replace inline styles with CSS classes

* i18n(artist): translate release-type labels in remaining 7 locales

Sayykii's `releaseTypes` namespace was added to en.ts only. Fills in
de, fr, nl, zh, nb, ru, es with the same 8 keys (album, ep, single,
compilation, live, soundtrack, remix, other) so users on non-English
UIs see translated section headers on the artist page instead of the
raw title-cased fallback.

* docs: changelog + contributors for PR #471

Adds the v1.46.0 "Added" entry and bumps Sayykii's contributors line
for the artist-page release-type grouping.

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-05-06 14:47:05 +02:00
Frank Stellmacher 81041c44c4 docs(readme): rewrite README and add Orbit banner (#478)
Restructures the README with a calmer tone and clearer information
architecture:
- new tagline + intro paragraph (less marketing-speak, more honest)
- badge row split into release/community/distribution groups,
  with a new Ko-fi support badge
- merged "Server Compatibility" + "Why Psysonic?" into a single
  "What is Psysonic?" section
- "Core Features" → "Highlights", restructured into Playback & Queue,
  Audio Tools, Library Management, Lyrics & Discovery, a new
  Sharing & Social Listening block (Magic Strings + Orbit), and
  Personalization & Accessibility
- Orbit is now treated as a current feature (no longer "Upcoming"),
  with a banner image and a paragraph about real-world use
- Privacy and Community sections expanded with proper links to
  Discord, Telegram, Issues and Ko-fi
- License + Forks-and-Attribution rewritten in a friendlier tone
  while still preserving the same intent

Assets:
- public/orbit.png — new Orbit banner
- public/screenshot1.png — refreshed app screenshot
2026-05-06 14:21:18 +02:00
Frank Stellmacher 8692e50603 feat(settings): Open Source Licenses section in System tab (#477)
* feat(licenses): tooling and initial data generation

Adds the maintainer-only generator that produces src/data/licenses.json:
- src-tauri/about.toml + about.hbs: cargo-about config + handlebars template
  for the Rust-side license enumeration
- scripts/generate-licenses.mjs: orchestrator that runs cargo-about and
  license-checker-rseidelsohn (via npx, no devDep), merges the outputs into
  a single per-crate JSON with full license texts, and writes the result to
  src/data/licenses.json

The script is invoked directly with `node scripts/generate-licenses.mjs` —
no npm script wrapper on purpose, since adding one to package.json would
trigger the nix-npm-deps-hash-sync workflow on every push.

Initial generation covers 575 cargo crates + 71 npm packages (646 entries
total, all with full license text bundled, ~1.4 MB JSON).

* feat(licenses): Settings panel UI

Adds a new Open Source Licenses section under Settings → System,
sitting below Contributors. Components:

- LicensesPanel.tsx: search input, curated highlight block of ~10 key
  dependencies (Tauri, React, rodio, symphonia, etc.), TanStack-Virtual
  list of all 600+ entries
- LicenseTextModal.tsx: full-screen-ish modal showing the bundled license
  text plus name/version/license-id badges + repository link
- licensesData.ts: lazy dynamic-import loader (Vite emits the JSON as a
  separate chunk, so the heavy ~1.4 MB payload is only loaded when the
  user actually opens the panel — no runtime fetch, the data is fixed
  into the build artifact)

The panel registers itself in the Settings in-page search index under
the System tab.

* feat(licenses): i18n in 8 locales

Adds the `licenses` namespace (title, intro, highlights, search
placeholder, no-results, loading / load error, no-license-text,
view-source, total line, generated-at) across en, de, fr, nl, zh, nb,
ru, es. License names themselves (MIT, Apache-2.0, GPL-3.0, …) stay
universal and are rendered as-is.

* docs(release): document licenses regeneration step

Adds a Step A.3 to the release SOP describing the maintainer-only
`node scripts/generate-licenses.mjs` workflow, the cargo-about
prerequisite, and explicitly notes why no npm script wrapper exists
(would trigger the nix-npm-deps-hash-sync workflow).
2026-05-06 14:17:13 +02:00
cucadmuh d48ea819c1 fix: stabilize preview seekbar, post-sleep audio recovery, and card hover behavior (#476)
* fix(player): freeze main seekbar during track preview

Preview pauses the main sink in Rust while isPlaying stays true in the
store, so WaveformSeek's interpolation rAF must not advance progress.

* fix(audio): recover output after sleep and stalled streams

Add platform-specific post-sleep recovery hooks for Windows and Linux, and add a watchdog that reopens the output stream when playback is active but sample progress stalls, so audio can recover without restarting the app.

* fix(ui): remove card hover lift and smooth artwork zoom

Remove vertical hover translation from album and artist cards, and move image fade transition out of inline styles so cover zoom uses CSS timing consistently.

* fix(player): prevent seekbar jump after preview ends

Reset interpolation anchor timing when preview freeze state changes so the main seekbar does not momentarily jump forward before resyncing.

* fix(audio): reduce false watchdog recoveries and add diagnostics

Arm stalled-output recovery only after long poll gaps that suggest sleep/resume, and add detailed watcher logs for arm/clear/trigger paths to diagnose unintended stream reopens.

* chore(ui): drop card GPU hints and clarify macOS sleep scope

Remove translateZ and will-change hints from album and artist cover images to avoid per-card compositing overhead on software-composited Linux paths, and document why post-sleep recovery hooks currently target only Windows and Linux.

* docs(audio): document intentional Win32 callback pointer lifetime

Add inline rationale for the two Box::into_raw pointers in Windows suspend/resume registration so future maintenance does not treat the process-lifetime pointers as accidental leaks.

* docs(changelog): summarize playback stability updates for PR #476

Add a high-level changelog entry for preview seekbar fixes, sleep/wake audio recovery hooks and watchdog diagnostics, and card-hover stability adjustments from PR #476.

* docs(contributors): add cucadmuh entry for PR #476

Logs the post-sleep audio recovery, preview-seekbar fixes and card
hover stability work in the Settings → System → Contributors list.
2026-05-06 13:28:38 +03:00
Frank Stellmacher 5c8cfb8be3 feat(settings): keep current active server when adding a new one (#475)
* feat(settings): keep current active server when adding a new one

Adding a server from Settings no longer auto-switches the active server.
The new entry appears in the server list and is immediately usable, but
playback context, queue, and library view stay on the server the user
was already on.

The previous setLoggedIn(true) call was redundant — Settings is behind
RequireAuth, so isLoggedIn is necessarily already true at this point.

Login flow is unchanged: signing in on /login still selects that server,
which is the explicit intent of that screen.

* docs: changelog + contributors for PR #475

Adds the v1.46.0 "Changed" entry and the Psychotoxical contributors
line for the no-auto-switch-on-add-server behaviour.
2026-05-06 11:43:22 +02:00
Frank Stellmacher 7f03a9536a docs: add NOTICE, TRADEMARK, and README forking guidance (#474)
* docs: add NOTICE and TRADEMARK files

Adds a NOTICE.md with the GPLv3 §7(b) attribution-preservation terms
that derivative works must carry forward, and a TRADEMARK.md that
covers the Psysonic name, logo, brand identity, and the names of
original Psysonic features (in particular Orbit) — none of which are
licensed under the GPLv3.

Forks remain free under GPLv3 to reuse the code, but must rename
Psysonic-branded assets and feature names, and must preserve the
attribution that original Psysonic features were designed and
implemented in this project.

* docs(readme): add Forks and Attribution section

Points readers at the GPLv3 freedoms while clarifying the expectations
for forks: keep the project name and feature names as documented in
TRADEMARK.md, preserve attribution as required by NOTICE.md, and do
not present original Psysonic work (such as Orbit) as independent
creations of a fork.
2026-05-06 10:17:28 +02:00
cucadmuh 5abe18d5b8 Perf/UI performance (#473)
* perf(ui): defer off-screen Artist grid paint + rAF overlay scrollbar

Apply content-visibility to artist tiles in .album-grid-wrap (aligned with
album cards). Coalesce OverlayScrollArea thumb updates to one requestAnimationFrame per scroll burst.

* perf(css): content-visibility on horizontal artist rails

Apply the same off-screen deferral as album cards to `.album-grid .artist-card`
(ArtistRow, Favorites, etc.).
2026-05-06 10:45:13 +03:00
cucadmuh de3c0d9da1 Feat/performance probe fps overlay (#472)
* feat(perf-probe): add FPS overlay toggle and tidy probe modal

Add optional rAF-based FPS readout controlled by a persisted probe flag.
Remove the separate keyboard shortcut. Collapse all phase sections by default.

* perf(fps-overlay): subscribe only to showFpsOverlay flag

Add usePerfProbeFlag so the overlay does not re-render when other probe
toggles change. Track the animation frame id with a loop-local variable.
2026-05-06 02:13:59 +03:00
cucadmuh c3d37546cf Feat/search improvements (#470)
* feat(covers): race sibling downscale vs fetch, search thumb priorities

Run getCoverArt and client downscale in parallel when another size of the
same cover is cached; first successful result wins and aborts the other path.
Await both branches so inflight bookkeeping does not detach early.

Extend the cover cache size roster so provisional siblings resolve for sizes
used in the UI (e.g. 400/600/800, 48/96).

CachedImage: fetchQueueBias for live/mobile search (artist thumbnails ahead of
albums in fetch-slot ordering); configurable observeRootMargin with a wider
default to prepare priority slightly before elements enter view.

Mobile search adds round artist-thumb styling; add shared cover blob downscale
helper.

* perf(image-cache): batch sibling IDB reads and guard cover size registry

Use one read transaction when probing IndexedDB for sibling cover keys.
Extract COVER_ART_REGISTERED_SIZES and add Vitest coverage so every literal
coverArtCacheKey(_, size) in src stays aligned with sibling invalidation.
Honor AbortSignal during JPEG encode in downscaleCoverBlob.
2026-05-06 01:26:30 +03:00
Frank Stellmacher 072bef473f fix(queue): restore pre-#419 Play-icon prefix on the active row (#469)
PR #419 replaced the small (10px) Play icon next to the active queue
title with an animated 3-bar eq-bars block prefixed before the row.
Restore the original Play-icon-in-title behaviour and drop the unused
isStorePlaying selector.
2026-05-05 23:40:07 +02:00
cucadmuh 9d30285ff1 Perf/UI cover cache mainstage (#468)
* 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.
2026-05-06 00:15:58 +03:00