Commit Graph

1384 Commits

Author SHA1 Message Date
cucadmuh ee044ece1a fix(now-playing): poll Live listener count every 30s in the background (#1125) 2026-06-18 21:22:35 +03:00
Psychotoxical fde7ab432f feat: split AutoDJ into a standalone playback feature (#1124)
* feat(playback): add transition-mode helper

Centralise the crossfade/AutoDJ/gapless mutual exclusivity in one place
instead of the scattered setter combinations across the toolbar, mini
player and settings. AutoDJ stays encoded as crossfade + trim-silence, so
the persisted flags and the audio engine are unchanged.

* feat(queue): split AutoDJ into its own toolbar button + playlist submenu

- AutoDJ becomes a standalone toolbar button (Blend icon) next to
  crossfade, driven by the shared transition-mode helper. The crossfade
  right-click popover drops the mode switch and keeps only the seconds
  slider.
- Save + load playlist collapse into one Playlist button opening a small
  submenu, freeing up toolbar space.
- queueToolbarStore gains a position-preserving rehydrate migration
  (legacy save/load -> playlist, autodj inserted after crossfade) with
  unit tests.
- Toolbar customizer and all 9 queue locales updated to match.

* feat(mini-player): standalone AutoDJ button, shared transition helper

Mirror the queue toolbar: AutoDJ gets its own Blend button, the crossfade
popover keeps only the seconds slider. The mini player now drives all
three transitions through a single additive `mini:set-transition-mode`
event handled by the shared helper, replacing the per-flag mini events.
Drop the now-dead crossfade-mode CSS.

* feat(settings): segmented track-transition picker, regroup playback

Replace the crossfade toggle + inner crossfade/AutoDJ switch with a single
Off | Gapless | Crossfade | AutoDJ segmented control (mirroring the
Normalization picker above), driven by the shared transition helper — the
mutual exclusivity now reads at a glance. Crossfade keeps its seconds
slider; AutoDJ shows its content-driven explainer. The block is regrouped
under "Track transitions" and "Queue behaviour" headings. Drops the now
unused crossfade/gapless description and not-available i18n keys across all
9 locales and adds the new transition strings.

* fix(queue): open the playlist submenu inward

The playlist submenu inherited the crossfade popover's right:0 anchor and,
sitting on the left of the toolbar, opened out under the main container.
Anchor it left:0 so it stays inside the queue panel. Update the toolbar
test for the new playlist submenu (save/load moved off the toolbar).

* feat(settings): box playback sub-sections into panels

Wrap Normalization, Track transitions and Queue behaviour each in their own
bordered panel with an accent uppercase header (new reusable .settings-group
classes), so the sections read as distinct blocks instead of one wall of
text. Drops the thin divider that separated them.

* docs: changelog, credits and what's new for AutoDJ standalone

Fold the standalone-AutoDJ changes into the existing AutoDJ entry in the
changelog and the in-app What's New, and add the credit (#1124).
2026-06-18 13:31:38 +02:00
cucadmuh f28e82c022 docs(whats-new): add user-facing highlights for 1.49.0 (#1123)
Add the in-app Highlights copy for the current dev line so What's New
and Changelog tabs show distinct content again.
2026-06-18 11:36:57 +03:00
ImAsra 0f580f58c8 Edit readme to add winget (#1088)
Added installation instructions for Windows Package Manager.
2026-06-18 11:19:01 +03:00
cucadmuh a6ee0668c8 feat(crossfade): AutoDJ — content-aware silence-trimming crossfade (#1122)
* feat(crossfade): add "trim silence between tracks" toggle

New persisted setting `crossfadeTrimSilence` (default off; existing
installs rehydrate off via the persist default-merge). Surfaced in
Settings -> Audio and in the crossfade popovers of the queue toolbar
and the mini-player.

Crossfade buttons now separate the two actions: left-click toggles
crossfade on/off, right-click opens the settings popover (seconds +
trim). Shared the mini popover positioning into useMiniAnchoredPopover
(now backing both volume and crossfade). Mini bridge carries
crossfadeSecs/crossfadeTrimSilence and gains mini:set-crossfade-secs /
mini:set-crossfade-trim-silence.

The actual silence-trimming playback behaviour is wired in a follow-up;
this commit only persists the user intent. i18n added across 9 locales.

* feat(crossfade): trim silence between tracks (waveform-driven)

Wire the actual silence-aware crossfade behind the (default-off)
crossfadeTrimSilence toggle. Detection is derived on the fly from the
cached 500-bin waveform + track duration — no new analysis pass or
cache fields.

- waveformSilence.ts: computeWaveformSilence(bins, duration) → lead/trail
  silence + content bounds, using the peak curve, a low absolute cut and
  a per-side cap. Unit-tested.
- A-tail (JS): handleAudioProgress advances the crossfade early, at
  contentEnd - crossfadeSecs, when the current track ends in real
  trailing silence, so the fade overlaps music. Guarded once per play
  generation.
- B-head: audio_play gains an additive optional start_secs; the freshly
  built source is try_seek'd past the next track's leading silence before
  append, then seek_offset/samples_played are re-anchored so position is
  content-relative. Non-seekable / cold sources degrade to today.
- Pre-buffer: crossfade next-track download + B-head probe moved to
  crossfadePreload.ts with a fixed ~30 s budget before the track needs to
  play (widened by trailing silence so the early advance keeps the
  budget). Also fired right after a seek into the window so jumping near
  the end still buffers in time.

Checks: tsc, vitest (store suite + new units), cargo test/clippy for
psysonic-audio.

* feat(crossfade): recommend hot cache for trim; probe B-head regardless

Add a "for reliable results, enable the Hot playback cache" note to the
trim-silence toggle description across all 9 locales — hot cache keeps
the next track on disk so it starts instantly past its lead silence.

Fix: the leading-silence probe (B-head) now runs even when hot cache is
on; only the redundant byte pre-download is gated on !hotCache. Without
this, enabling hot cache (the recommended setting) would have skipped the
probe and disabled leading-silence trimming.

* feat(crossfade): content-driven smart crossfade overlap

Smart crossfade derives the per-transition overlap purely from the
waveform envelopes — max(A outro fade, B intro rise) clamped 0.5–12s —
instead of the fixed crossfadeSecs ("work by fact"). The JS early
advance arms the computed overlap and audio_play applies it through a
new crossfade_secs_override (capping only this swap's fade); plain
loud→loud endings fall back to the engine crossfade at crossfadeSecs.

* feat(crossfade): "Crossfade | Smart crossfade" mode switch in UI

Replace the standalone "trim silence" toggle with a Crossfade / Smart
crossfade segmented control in settings and both crossfade popovers
(queue toolbar + mini-player). Classic Crossfade shows the seconds
slider; Smart crossfade is content-driven with no duration to set.
Adds smartCrossfade / smartCrossfadeDesc strings to all nine locales
and the "smart crossfade" search keyword.

* feat(crossfade): don't double-fade a track that already fades out

Decouple the outgoing track's fade-out from the incoming fade-in. When
A carries its own recorded fade-out (outroFadeA ≥ 1s and ≥ B's intro
rise), planCrossfadeTransition now sets outgoingFadeSec = 0; the engine
then skips A's TriggeredFadeOut so A keeps full gain and its recording
carries it down while B rises underneath — no more double attenuation
that made A vanish early and B blare in. Hard-cut endings still get an
engine fade over the overlap.

audio_play gains outgoing_fade_secs_override (Some(0) = ride A's own
fade), threaded via SinkSwapInputs.outgoing_fade_secs; the JS advance
arms it alongside the overlap.

* feat(crossfade): rename the smart crossfade mode to "AutoDJ"

User-facing rename of the content-driven crossfade mode from "Smart
crossfade" to "AutoDJ" across the settings segmented switch, the queue
and mini-player popovers, all nine locales, and the settings search
keywords. The underlying store flag (crossfadeTrimSilence) is unchanged.

* feat(crossfade): standard ~2s blend for hard loud→loud meetings

When AutoDJ trims a track's protective trailing silence and the loud
ending butts straight into a loud intro, neither edge fades, so the old
0.5s anti-click floor sounded like an abrupt cut. Use a standard ~2s
equal-power crossfade for that case (both edges analysed, nothing
fades); real fade-outs/buildups keep their longer content-driven span,
and the bare floor only survives when an envelope is missing.

* fix(crossfade): keep B's fade-in across the B-head start-offset seek

EqualPowerFadeIn::try_seek jumped straight to unity gain for any seek
≥100ms, which also hit the initial start-offset seek that skips the
incoming track's leading silence — so a crossfaded track with trimmed
lead silence popped in at full gain instead of fading in. Only skip the
fade-in for mid-playback seeks (sample_count > 0); a seek before any
audio has played keeps the fade-in.

* feat(crossfade): gate AutoDJ early fade on next-track readiness

The early, content-driven advance now fires only when the next track's
audio is actually available — in the engine RAM preload slot
(enginePreloadedTrackId) or local on disk (offline library, favourite-auto
or hot-cache ephemeral) — via isCrossfadeNextReady(). Analysis alone is
not enough: a cold, still-buffering stream would fade in over silence.

When B isn't ready the gen guard stays unset so it re-checks on later
ticks; if B never readies, the plain engine crossfade handles the
transition (graceful degrade) instead of a broken fade. A RAM preload
copy suffices — the full track need not be cached to disk.

* feat(crossfade): suppress engine auto-crossfade for AutoDJ + eager preload

With the hot cache off the readiness gate alone wasn't enough: the engine's
progress task autonomously fires its crossfade audio:ended ~crossfadeSecs
before the end, independently of JS, and would start a still-buffering next
track and fade over it — an audible jump.

- Engine: add autodj_suppress_autocrossfade flag (audio_set_autodj_suppress);
  the progress task treats it like crossfade-off, so the early timer never
  fires and audio:ended only comes from real source exhaustion / watchdog.
- JS drives the transition: set the flag when a content fade is pending
  (wantEarly) and clear it for plain loud→loud / non-AutoDJ, so the normal
  engine crossfade is preserved there. When the next track never readies, A
  plays out and we degrade to a clean sequential start instead of a jump.
- audio_preload gains an `eager` flag; the crossfade/AutoDJ pre-buffer passes
  it to skip the 8s start throttle so the RAM slot fills before the fade.

* docs(changelog): AutoDJ content-aware crossfade (PR #1122)

Add the 1.49.0 Added entry and the cucadmuh credits line for AutoDJ.
2026-06-18 02:15:20 +03:00
Psychotoxical ed52a9991f feat(albums): "Artist → Year" album sort option (#1120)
* feat(albums): "Artist → Year" sort option

Adds a third album browse sort that groups albums by artist and orders each
artist's albums chronologically (oldest first, title as a same-year tiebreak)
— the double-sort requested in #1113. The local index sorts globally via
[{artist},{year},{name}]; the server fallback fetches by artist and applies the
year ordering per page (Subsonic has no compound sort).

* i18n(albums): Artist → Year sort label (9 locales)

* docs(changelog): add Artist → Year to the album sorting entry (#1120)
2026-06-17 22:50:25 +02:00
Psychotoxical ad74578ef6 feat(playlists): local playlist folders (sidebar + page, DnD, view toggle) (#1119)
* feat(playlists): playlist folder model — store + pure grouping core

Local, per-server folder layer over the server's flat playlist list (the
Subsonic API has no folder concept). Adds:
- playlistFolders.ts: shared types + pure groupPlaylistsByFolder (used by
  every surface), with full unit coverage.
- playlistFolderStore.ts: persisted Zustand store (create/rename/delete/
  assign/collapse), per-server scoped; deleting a folder drops its
  assignments so playlists fall back to ungrouped.

UI surfaces (sidebar + Playlists page) build on this in following commits.

* i18n(playlists): folder strings across all 9 locales

Adds the playlists.folders.* namespace (folder names, move/remove, expand/
collapse, group-by-folders toggle, count plurals, and the local-only notice
explaining that Navidrome and the Subsonic API have no native folder support).

* feat(playlists): folder views, drag-to-folder, move-to-folder menu, view toggle

Surfaces the local folder layer on both the Playlists page and the sidebar:
- Page: collapsible folder sections + ungrouped remainder, each reusing
  VirtualCardGrid; flat grid is kept verbatim when no folders exist or the
  group view is toggled off.
- Drag-to-folder via the shared mouse-based psy-drop system (HTML5 DnD is
  unusable in WebKitGTK); the whole section is the drop zone, and the
  ungrouped zone appears during a drag so a playlist can always return to root.
- "Move to folder" submenu in the playlist context menu (keyboard-accessible
  path; also creates folders on the fly) — stays available offline.
- Header gets a "New folder" action and a "Group by folders" view toggle
  (both context-aware); a notice surfaces the local-only caveat.
- Sidebar renders the same collapsible folder groups.
- groupView preference added to the folder store.

* docs(changelog): note playlist folders (#1119)
2026-06-17 21:22:30 +02:00
cucadmuh ccb2d11fc4 fix(deps): bump transitive form-data to 4.0.6 (GHSA-hmw2-7cc7-3qxx) (#1118)
* fix(deps): bump transitive form-data to 4.0.6 (GHSA-hmw2-7cc7-3qxx)

Close Dependabot alert #18: axios pulls form-data for multipart bodies;
lockfile now pins the patched 4.0.6 release (CVE-2026-12143).

* docs: changelog and credits for form-data security bump (PR #1118)

* revert: drop settingsCredits entry for form-data bump

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-17 19:10:52 +03:00
Psychotoxical 44d373d7bb fix(player): player bar context menu acts on the current song, not its album (#1117)
* fix(player): player bar context menu acts on the current song, not its album

Right-clicking the current track in the player bar built an album object
from the playing track and opened the album context menu, so "Add to
playlist" added the whole album instead of the song. It now opens a
song-scoped menu for the current track. Left-click on the title still
navigates to the album.

* docs(changelog): note player bar add-to-playlist fix (#1117)
2026-06-17 17:07:47 +02:00
Psychotoxical 116196f0d4 fix(albums): order each artist's albums by title when sorting by artist (#1115)
* fix(albums): order each artist's albums by title when sorting by artist

Browsing albums by artist left the albums within each artist in an
undefined order. The local-index sort only emitted the artist key; it
now appends album title as a secondary key (and artist as the tiebreak
for the by-name sort), matching the network path's per-page ordering.

* docs(changelog): note album sort-within-artist fix (#1115)
2026-06-17 16:50:47 +02:00
Psychotoxical 68b21643f8 fix(windows): restore taskbar thumbnail media buttons after deferred window show (#1112)
* fix(windows): restore taskbar thumbnail media buttons after deferred window show

The Prev / Play-Pause / Next buttons in the Windows taskbar thumbnail
preview stopped appearing. The taskbar code was unchanged; the regression
came from the main window now starting hidden with a deferred show. ThumbBarAddButtons was still called at setup time, before the shell had
created the window's taskbar button, so it returned S_OK but added
nothing (no error logged).

Register the shell's "TaskbarButtonCreated" message and add the buttons
from the window subclass when it fires (first show, and again after an
explorer restart), which is the documented requirement for ThumbBarAddButtons.

* docs(changelog): note Windows taskbar media buttons fix (#1112)
2026-06-17 15:28:38 +02:00
Psychotoxical 8498d5a566 chore(audio): declare windows Power/WindowsAndMessaging features in psysonic-audio (#1111)
power_notify_win.rs imports windows::Win32::System::Power and
UI::WindowsAndMessaging, but the crate only declared Foundation/Com/Threading.
It compiled solely via workspace feature unification (the root src-tauri crate
enables them), so building or testing psysonic-audio in isolation on Windows
(cargo check/test -p psysonic-audio) failed with E0432 unresolved imports.
Declare the two features the crate actually uses so it builds standalone.
No behaviour change; workspace/release builds already had these features.
2026-06-17 12:55:39 +02:00
cucadmuh 3ec65a6407 fix(audio): make seeking work on streamed Opus/Ogg via on-demand HTTP Range (#1110)
* fix(audio): make seeking work on streamed Opus/Ogg via on-demand HTTP Range

Seeking inside an Opus/Ogg track streamed over ranged HTTP was a contained
no-op (the seekbar snapped back); it only worked once the track had fully
downloaded/cached. symphonia 0.6's Ogg demuxer seeks by bisecting the byte
range (reading pages at midpoints across the whole file) and scans the last
pages during the probe, but RangedHttpSource only filled the buffer linearly
from offset 0, so any read ahead of the download front blocked until the
linear download caught up. Keeping Ogg seekable through the probe without a
real random-access source would have forced a full pre-download.

Add an on-demand random-access fetcher to RangedHttpSource: when a read
lands well ahead of the contiguous linear download (a seek, a bisection
midpoint, or the end-of-stream probe), fetch the needed range over HTTP Range
(1 MiB window) on the tokio runtime and let the read loop poll for it, instead
of blocking on the linear filler. Ranged Ogg now stays seekable through the
probe (records its byte range) so seeking works for real; the catch_unwind in
try_seek stays as a safety net.

- New OnDemand fetcher writes arbitrary ranges into the shared buffer (same
  bytes the linear download would write; idempotent under the buffer mutex,
  mirroring the existing MP4 moov tail-prefetch). It never touches
  downloaded_to/done, so full-download completion and the track-analysis seed
  are unaffected.
- On-demand only fires on a forward gap > 512 KiB, so normal sequential
  read-ahead (and a slightly starved play cursor) still waits for the linear
  download without spurious range requests.
- ranged-stream now passes random_access=true; preview keeps on_demand=None.

Does not touch the Tauri boundary (no invoke/event changes).

* docs(changelog): add 1.49.0 entry for streamed Opus/Ogg seeking (#1110)

Also credit the streamed-seek work in settingsCredits.

* fix(audio): require 206 for ranged Range fetches at a non-zero offset

Address PR #1110 review note: ranged_write_http_range accepted a 200 the
same as a 206. A server that ignored the Range header and replied 200 returns
the whole body from byte 0; writing that at a non-zero offset would corrupt
the buffer (affects both the on-demand seek fetcher and the MP4 moov-tail
prefetch). Accept 200 only when the request started at offset 0; otherwise
require 206.
2026-06-17 13:49:50 +03:00
Psychotoxical 47b09d6f25 chore(aur): bump PKGBUILD to v1.48.1 (#1107) 2026-06-17 02:41:50 +02:00
cucadmuh 1e956d6043 Merge pull request #1106 from Psychotoxical/fix/backport-1.48.1
Backport 1.48.1 fixes to main
2026-06-17 01:47:22 +03:00
cucadmuh 82967caa9c docs: add 1.48.1 release section to CHANGELOG and WHATS_NEW
Backport of the 1.48.1 hotfix notes onto main (which is on 1.49.0-dev).
Insert the released [1.48.1] section between [1.49.0] and [1.48.0] in
CHANGELOG.md (all eight Fixed entries with their attribution, identical to the
fix/1.48.1 branch) and the matching [1.48.1] What's New section. Application
version is intentionally left at 1.49.0-dev — only the notes are carried over.
2026-06-17 01:32:40 +03:00
Psychotoxical a6122f9db4 fix(windows): transcode WebP cover to PNG for the media controls (#1102)
Windows SMTC could not render our cached WebP album covers: souvlaki loads the
file and SetThumbnail/set_metadata succeed, but the lock screen and Quick
Settings media tile showed a blank cover, because the OS thumbnail decoder does
not handle WebP even with the Store WebP extension installed.

Transcode local file:// WebP covers to PNG (libwebp decode then image PNG
encode, into a single reusable temp file) before handing them to the OS media
controls, gated to Windows. macOS (ImageIO) and Linux pass through unchanged.

(cherry picked from commit 76d028127d)
2026-06-17 01:30:36 +03:00
Psychotoxical 6d63365c2a fix(windows): show app name in media controls via AppUserModelID (#1102)
The Windows system media controls (Quick Settings media tile, lock screen,
third-party media flyouts) labelled playback as "Unknown application" with no
icon, because souvlaki creates the SMTC via GetForWindow and Windows resolves
the source name from the process AppUserModelID, which was never set.

Call SetCurrentProcessExplicitAppUserModelID early in run() so the process has
an explicit identity that matches the installer shortcut's AppUserModelID;
Windows then resolves the name and icon to Psysonic.

(cherry picked from commit 4fd85f2dd4)
2026-06-17 01:30:23 +03:00
cucadmuh 6168e81195 fix(library): use partial indexes for §6.9 remap lookup (#1105)
* fix(library): use partial indexes for §6.9 remap lookup

The delta-sync remap detection ran a single lookup with an OR across
content_hash and server_path. SQLite could not use the partial
idx_track_remap_hash / idx_track_remap_path indexes for it: a partial
index is only applied when the query's WHERE provably implies the index
predicate (… != ''), and an OR spanning two columns blocks the per-branch
index plan. The query degraded to a full track scan on every incoming
row → O(rows × catalog), causing multi-minute stalls on large libraries
(observed upsert_batch_remap exec_ms=162001 on a ~200k-track Navidrome
sync, which in turn blocked all other writers on the single write mutex).

Split it into two single-column lookups that each repeat the index
predicate so the planner picks the matching partial index (SEARCH, not
SCAN); hash is checked first, matching §6.9 strong-key priority. Adds an
EXPLAIN QUERY PLAN regression test asserting index usage.

* docs(changelog): note remap-lookup sync stall fix (PR #1105)

(cherry picked from commit bca0acbaff)
2026-06-17 01:30:23 +03:00
cucadmuh 067ed00ae2 fix(audio): fix Opus/Ogg seek crash (symphonia do_seek panic) (#1100)
* fix(audio): fix Opus/Ogg seek crash by keeping random-access sources seekable through probe

Scrubbing the seekbar on Opus/Ogg files (then pressing Stop) crashed the whole
app. symphonia 0.6's Ogg demuxer records the physical stream's byte range only
when the source is seekable during the probe, but ProbeSeekGate hid seekability
there — so phys_byte_range_end stayed None and the first seek hit
Option::unwrap() on None on the cpal audio thread. That poisoned the engine
mutexes and aborted the process at the non-unwinding cpal FFI boundary (the
"crash on Stop" was a downstream symptom).

- Keep Ogg/Opus seekable through the probe on random-access sources (local
  files, in-memory) so the demuxer computes its seek bounds and seeking works
  for real. Progressive ranged-HTTP keeps the gate to avoid forcing a full
  download before playback starts.
- Contain any demuxer unwind inside SizedDecoder::try_seek (catch_unwind) so a
  panic on the audio thread can no longer poison engine state — covers streamed
  Ogg (still gated) and any future demuxer panic.
- Thread a random_access flag through PlayInput::SeekableMedia and new_streaming.

* docs(changelog): add 1.48.1 entry for the Opus/Ogg seek crash fix

Also note the Opus seek crash fix in WHATS_NEW.md (1.48.1).

(cherry picked from commit 8bfde08199)
2026-06-17 01:30:23 +03:00
Psychotoxical 16e562b42d fix(window): honour minimize-to-tray on the macOS close button
The macOS red close button always emitted app:force-quit, which exits
unconditionally and never checked the minimizeToTray setting — so on macOS
the window closed the app even with "Minimize to Tray" enabled. Route the
main-window close through window:close-requested on all platforms, so JS
decides hide-vs-exit from the setting (default off = unchanged quit). The
tray "Exit" item still force-quits.

Fixes #1103

(cherry picked from commit acd6f12aba)
2026-06-17 01:29:07 +03:00
Psychotoxical 961dba996c fix(discord): resolve cover profile from the store, not getPlaybackServerId
Follow-up to the dual-address cover fix: a playback/active cover scope was
routed through getPlaybackServerId(), which returns a `string` that can be
empty or an index-key (not a profile id) for locally-cached tracks. The
`?? activeServerId` fallback never fired (empty string isn't nullish), so no
profile matched and the URL came back null — which the presence layer caches
per cover id, producing intermittent "cover shows, then doesn't".

A playback/active scope always means the active server (a cross-server track
gets an explicit `server` scope), so resolve the active profile directly.

(cherry picked from commit 8f93f30e6f)
2026-06-17 01:29:07 +03:00
Psychotoxical 4fd558fa28 fix(discord): use the public server address for Rich Presence cover art
The Discord cover URL was built via the connect endpoint, which prefers the
LAN address — but Discord fetches the image from its own servers, so a LAN
address is unreachable and the cover falls back to the app icon. This is a
dual-address regression: before a second (public) address could be added,
the only configured URL was the public one.

Build the Discord large-image URL via serverShareBaseUrl (public preferred,
like share links / Orbit invites) instead of the connect URL. Adds a test.

(cherry picked from commit 5f15784b7d)
2026-06-17 01:29:07 +03:00
Psychotoxical 42dcbb9323 fix(audio): bump generation on paused device reopen to stop spurious audio:ended
Third defence for #1094: on a device change while paused/stopped,
reopen_output_stream stopped the old sink without bumping the engine
generation (the bump only happened on a successful internal resume). The
still-running progress task could then flip done_flag and emit a spurious
audio:ended, which the frontend turns into a restart. Bump the generation
before sink.stop() in the non-playing case so the progress task bails out;
the active-playback path keeps bumping inside try_resume_after_device_change.

(cherry picked from commit 9034882bf6)
2026-06-17 01:29:07 +03:00
Psychotoxical 997e697a53 fix(audio): don't restart playback on device change when engine is paused
Defence-in-depth for #1094: the device-changed/-reset handlers restarted
playback based on the UI `isPlaying` flag alone, which can be stale or
desynced at the moment of a device change. Gate the restart on the
engine-paused flag as well (`isPlaying && !getIsAudioPaused()`), so a
paused engine never auto-restarts regardless of how `isPlaying` got set;
when paused it just resets for the cold path. Adds a regression test.

(cherry picked from commit 588dd8c48d)
2026-06-17 01:29:07 +03:00
Psychotoxical 4c0dfaaada fix(media-controls): honour explicit Play/Pause from OS media keys
Toggle, Play and Pause from the OS media-control bridge (souvlaki) were
all collapsed onto the play-pause toggle event. On an audio-route change
(e.g. macOS sending an explicit Pause when headphones disconnect) this
turned the pause into a toggle, resuming paused playback on the new
output device.

Map Play and Pause to dedicated media:play / media:pause events (the
frontend handlers already exist); only a real toggle key emits
media:play-pause. Paused playback now stays paused across device changes.

Fixes #1094

(cherry picked from commit f04bfb3d35)
2026-06-17 01:29:07 +03:00
Psychotoxical e563749ace fix(queue): stop re-walking the whole queue on every track change
QueueHeader aggregated total/future duration in a useMemo keyed on queueIndex,
so every skip ran a synchronous O(n) pass over the entire queue (resolveQueueTrack
per item). On very large queues this blocked the main thread for seconds — the
UI froze on skip and on the device-switch playTrack fallback (#1072; the freeze
half of #1090). QueuePanel is always mounted, so it hit even with the queue
collapsed.

Build a cumulative-duration prefix keyed on queue/resolver-version only; the
future-tracks total is now an O(1) lookup per skip. Display output unchanged.

(cherry picked from commit 7e91a5b2a1)
2026-06-17 01:29:07 +03:00
Psychotoxical 15fb0f6c56 Theme store: version display + animated/static filter (#1104)
* feat(themes): show theme version in the store and installed list

* feat(themes): filter the theme store by animated / static

* docs(changelog): add 1.49.0 entry for theme store version + animated filter
2026-06-16 20:52:02 +02:00
Psychotoxical 6d404fdc2d chore(aur): bump PKGBUILD to 1.48.0 (#1093) 2026-06-15 00:34:18 +02:00
github-actions[bot] c453f01b94 chore(release): bump main to 1.49.0-dev (#1091)
* chore(release): bump main to 1.49.0-dev

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-14 22:45:00 +02:00
Psychotoxical 07232cea9a fix(fullscreen-player): honour the Lyrics scroll style setting (#1089)
The rebuilt fullscreen player hardcoded Apple-style lyrics scrolling (active
line ~35% from the top) and ignored Settings -> Lyrics -> Lyrics scroll style,
which still drives the sidebar/mobile LyricsPane. FsLyricsApple now reads
sidebarLyricsStyle: 'classic' centres the active line, 'apple' keeps the 35%
anchor, matching the sidebar lyrics.
2026-06-14 13:37:25 +02:00
cucadmuh 6f555bdc96 docs: sync WHATS_NEW with 1.48 changelog, fix Fixed PR order (#1087)
Add highlights for Live status dots, playback Semitones, Linux title bar
styles, Music Network self-hosted scrobble fix, and Navidrome radio admin
gating. Remove meta What's New page section. Re-sort CHANGELOG Fixed
entries (#1085/#1086 were at the top).
2026-06-14 03:16:31 +03:00
Psychotoxical c1403f8bd6 feat: now-playing liveness dot + admin-gated radio management (#1086)
* feat(now-playing): liveness indicator dot in the listening popover

Replace the raw "Nm ago" line in the "Who is listening?" popover with a
derived presence dot (green playing / amber paused / dim idle). The presence
is computed in one tested helper that unifies the playbackReport transport
state with the legacy getNowPlaying recency, instead of formatting a raw
timestamp inline. The dot carries the localized status as an aria-label and
tooltip so it is not conveyed by colour alone.

* feat(radio): gate station create/edit/delete behind Navidrome admin role

Navidrome >= 0.62 restricts internet-radio management to admins
(GHSA-jw24-qqrj-633c); non-admin requests fail. Hide Add Station, Search
Directory, the per-card edit chip and delete button for confirmed standard
Navidrome users via a canManageNavidromeRadio() helper on the existing
useNavidromeAdminRole framework. Admins, non-Navidrome servers and transient
states stay unrestricted; playback and favourites remain available to all.

* docs(changelog): now-playing status dot + admin-gated radio (#1086)
2026-06-14 01:30:25 +02:00
cucadmuh 0b7d9eae2d feat(playback): Semitones strategy, 2-decimal speed label, advanced fine steps (#1084)
* feat(playback): varispeed-by-semitones strategy and 2-decimal speed label

Adds a fourth playback-rate strategy "Varispeed (semitones)" — a frontend
lens over varispeed where the user dials the pitch change directly in
semitones (±12 st, 0.1 step) and speed = 2^(st/12). Engine contract is
unchanged: the store maps it to the existing "varispeed" Rust strategy via
engineStrategy(), and switching between the two varispeed lenses at the same
speed no longer restarts the track.

Also widens the speed readout to two decimals (formatSpeedLabel → toFixed(2))
so every 0.05 slider step is visible (1.05×, 1.10×, 1.15×), addressing the
feedback on issue #531 that the label looked stuck between steps.

Includes new i18n keys and updated hint across all 9 locales, plus Vitest
coverage for the new helper, label formatting, and the no-restart lens switch.

* refactor(playback): shorten Semitones strategy label, add per-strategy tooltips

Renames the fourth strategy to a concise "Semitones" (was "Varispeed
(semitones)") across all 9 locales, and lets the four strategy buttons share
the row width so they fit on one line in Settings while still wrapping in the
narrow player popover. Each strategy button now has a short hover tooltip
explaining how it is built (incl. the 2^(st/12) and 12 × log2(speed) maths).

* feat(playback): advanced fine-step precision for speed/pitch sliders

Adds an opt-in "Fine adjustment" toggle in Settings → Audio (visible only in
Advanced mode) that shrinks the playback-rate slider steps to 0.01× for speed
and 0.01 st for pitch/semitones, with the pitch readout widening to two
decimals to match. Default behaviour (0.05× / 0.1 st) is unchanged. The
preference is UI-only (persisted, not sent to the engine) and also applies to
the player-bar popover and wheel. Addresses the finer-precision request on
issue #531 without cluttering the default UX.

* docs: changelog and credits for playback speed follow-up (PR #1084)
2026-06-14 01:32:20 +03:00
Psychotoxical 41c8187186 fix(music-network): keep API suffix for self-hosted paste-token providers (#1085)
* fix(music-network): keep API suffix for self-hosted paste-token providers

The api_key_only connect strategy persisted the raw origin from the baseUrl
field instead of the resolved API base, dropping the preset's
selfHostedApiSuffix (e.g. /apis/listenbrainz). Scrobbles and now-playing then
hit <origin>/1/submit-listens (404/405, silently unlogged) instead of
<origin>/apis/listenbrainz/1/submit-listens, so nothing was recorded.

Return the runtime-resolved ctx.baseUrl (origin + suffix) and fall back to the
field only when it is absent. Fixes Koito and both Maloja compat surfaces
(ListenBrainz and Audioscrobbler). Existing accounts must reconnect to
re-persist the corrected base.

* docs(changelog): self-hosted scrobble URL fix (#1085)
2026-06-14 00:27:30 +02:00
Psychotoxical 028eb65f7d feat(titlebar): selectable window button styles + minimize toggle (#1083)
* feat(titlebar): selectable window button styles + minimize toggle

Custom title bar (Linux) gains a window-button style picker, mirroring
the seekbar style picker pattern. Six form-named styles: dots, dotsGlyph,
flat, pill, outline, glyph. All buttons now carry minimize/maximize/close
glyphs for clear, colour-blind-friendly iconography; dots reveal glyphs on
hover, dotsGlyph always shows them.

- New authStore state windowButtonStyle (default dots) + showMinimizeButton,
  with rehydrate validation falling back to dots on unknown values.
- WindowButtonPreview reuses the real .titlebar-btn classes for WYSIWYG tiles.
- Picker + minimize toggle render under the Custom title bar setting, gated
  on the toggle being on.
- Monochrome styles use --text-primary glyphs and stronger borders for
  contrast on dark themes.
- Dev-build grey marker scoped to the real title bar so previews show true
  colours.
- i18n keys in all 9 locales; setter and rehydrate tests.

* docs(changelog): window button styles (#1083)
2026-06-13 23:52:56 +02:00
cucadmuh be3f1dc299 docs(whats-new): refresh 1.48.0 release highlights (#1082)
* docs(whats-new): refresh 1.48.0 highlights for release screen

Reorder and expand user-facing copy to match CHANGELOG Added order,
add Music Network and Live, drop stale About/licenses line, replace
the vague Fixed placeholder with grouped fixes, and add a short Under
the hood section for significant non-UI work.

* docs(whats-new): order 1.48 sections by user impact

Sort Highlights, Fixed groups, and bullets within each block so the
most noticeable changes for everyday use appear first.

* docs: CHANGELOG 1.48 strict PR order; clarify WHATS_NEW sorting

Reorder Added/Changed/Fixed entries in CHANGELOG [1.48.0] by ascending PR
number. WHATS_NEW keeps impact-based order; add a maintainer note that
the two files intentionally differ.
2026-06-13 06:03:21 +03:00
cucadmuh 52fbb33b00 chore(deps): bump esbuild to 0.28.1 (Dependabot #17) (#1081)
* chore(deps): bump esbuild to 0.28.1 for GHSA-g7r4-m6w7-qqqr

Closes Dependabot alert #17 — path traversal in esbuild dev server
(--servedir) on Windows; patched in 0.28.1.

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-13 05:51:19 +03:00
Kris Bennett 947711a98d fix(installer): stderr logging, /dev/tty reinstall prompt, hardened download (#1079)
* fix(installer): write logs to stderr so the download URL isn't polluted

* fix(installer): read the reinstall prompt from /dev/tty

* fix(installer): add --fail --globoff to the package download

* fix(installer): degrade gracefully when no controlling terminal is available
2026-06-13 02:36:20 +00:00
cucadmuh 891ab0dd5b feat(now-playing): OpenSubsonic playbackReport for live now-playing (#1080)
* feat(now-playing): adopt OpenSubsonic playbackReport for live now-playing

Drive a small playback state machine (starting → playing ↔ paused → stopped)
on the Subsonic-server channel when the server advertises the OpenSubsonic
`playbackReport` extension (Navidrome ≥ 0.62), giving `getNowPlaying` a real
transport state and an extrapolated position. Reports send `ignoreScrobble=true`
so play counts stay on the existing `scrobble.view` 50% path (no double count),
and the effective playback speed is included so the server extrapolates position
correctly with the speed feature on.

- New `playbackReportSession` FSM mirrors the existing `playListenSession`
  lifecycle hooks and is wired at the same player call sites (start / gapless
  switch / queue restore / resume / 15s heartbeat / pause / seek / stop / ended /
  error / app quit). Servers without the extension degrade to the unchanged
  legacy `scrobble.view?submission=false` presence call.
- Gated through the existing serverCapabilities framework: a new
  `FEATURE_PLAYBACK_REPORT` (auto, extension-detected). The OpenSubsonic
  extensions probe now stores the full advertised list once and serves both
  AudioMuse `sonicSimilarity` and `playbackReport` from it, without disturbing
  the legacy Instant Mix opt-in on pre-0.62 servers.
- Now Playing dropdown shows a live position bar and a paused indicator.
- reportPlayback uses the real request params (mediaId / mediaType / positionMs).

Tests: FSM transitions + gating + legacy fallback, capability resolution,
extension-list probe storage/decoupling. Full suite green; no Tauri-boundary
changes.

* feat(now-playing): glide the Live position bar between polls

Extrapolate the position of `playing` entries locally (elapsed × reported
playbackRate from the last 10 s poll) and re-render once a second, so the Live
progress bar advances smoothly instead of jumping on each refresh. Paused and
position-less entries stay frozen. A linear width transition matched to the tick
keeps the fill gliding. Only applies to clients that report a position via the
playbackReport extension.

* fix(now-playing): tighten Live timer layout and report resume immediately

Keep the progress-bar width stable without a wide empty gap before the
timer: reserve ~2ch inside the current-time span only (right-aligned), not
on the whole clock block. Report `playing` to the server as soon as
resume() runs, matching the immediate `paused` report on pause instead of
waiting for the Rust `audio:playing` event.

* docs: CHANGELOG and credits for playbackReport live now-playing (PR #1080)
2026-06-13 05:31:26 +03:00
cucadmuh abc2c0b579 refactor(audio): split source-build pipeline into source_build module (#1074)
Move the source-building pipeline out of play_input.rs into a focused
source_build.rs: BuildSourceArgs/PlaybackSource, the ranged-stream probe
fallback (build_playback_source_with_probe_fallback) and its private helpers
(build_source_from_play_input, wait_or_fetch_bytes_for_stream_fallback, etc.).
play_input.rs now only handles source *selection* and drops from 799 to 420
lines; helpers that became module-internal lose their pub(super) visibility.
No behavior change.
2026-06-12 19:41:17 +03:00
cucadmuh 184e87a469 fix(audio): release idle output stream after 60s (#1071) (#1073)
* fix(audio): release idle output stream after 60s (#1071)

Lazy-open CPAL on first playback and close the device handle after one
minute without active audio so Windows can sleep; emit output-released
for cold resume and skip post-wake reopen when idle.

* docs: CHANGELOG and credits for idle audio stream fix (PR #1073)

* fix(audio): satisfy clippy if-same-then-else in idle watcher

* fix(audio): silence rodio DeviceSink drop unless logging is debug

Gate log_on_drop(false) on runtime should_log_debug() so normal/off
logging modes avoid stderr noise from intentional idle stream release.

* feat(audio): cold-start paused restore and silent engine prepare

After getPlayQueue on startup, apply saved seek position to the UI,
prefetch the current track to hot cache, and load the engine paused via
new audio_play startPaused so playback does not audibly start before
pause. Shared engineLoadTrackAtPosition with queue-undo restore.

* fix(audio): satisfy clippy too_many_arguments on stream arm helper

Bundle spawn_legacy_stream_start_when_armed parameters into
LegacyStreamStartWhenArmed so workspace clippy passes.

* fix(audio): release output stream immediately on stop (#1071)

Stop and natural queue end call audio_stop; close the CPAL device right
away instead of waiting for the 60s idle timer. Pause keeps the grace
period for warm resume.

* fix(audio): keep waveform mounted after stop (#1071)

Stop preserves currentTrack, so its cached analysis waveform stays valid.
Stop no longer nulls waveformBins for the still-shown track and re-hydrates
them from the analysis DB, instead of dropping to flat placeholder bars.

* test(audio): cover output_stream_is_needed branches; harden audio_play arg (#1071)

- Add unit tests for the idle-keepalive decision: empty/playing/paused main
  sink, preview and fading-out sinks, and radio playing/paused. Players are
  built device-less via rodio's Player::new + a Zero source, so empty()/state
  are exercised without an audio device.
- Make audio_play's `start_paused` an Option<bool> defaulting to false, so the
  new field is strictly additive (omitting startPaused no longer fails serde).
- Drop the unused `_engine` parameter from start_stream_idle_watcher; it
  resolves the engine from the AppHandle each poll.

* refactor(audio): extract sink-swap lifecycle into sink_swap module (#1071)

Move SinkSwapInputs/swap_in_new_sink and the legacy stream-arm helper
(LegacyStreamStartWhenArmed/spawn_legacy_stream_start_when_armed) out of
play_input.rs into a focused sink_swap.rs, so source selection and source
building stay separate from sink lifecycle. play_input.rs drops from 953 to
799 lines. No behavior change.
2026-06-12 17:13:51 +03:00
cucadmuh 80822fd742 fix(themes): apply Theme Store themes in production release builds (#1070)
Release CSP blocked runtime <style> injection for community themes; add
explicit style-src/style-src-elem and font-src. Derive the effective theme
synchronously in useThemeScheduler so data-theme updates on the same commit.
2026-06-12 03:13:18 +03:00
Psychotoxical 4902c0e25b Fix MPRIS player duplication during internet radio (#1048) (#1069)
* fix(mpris): disable WebKit media session so radio doesn't duplicate the player

Internet radio plays through an HTML <audio> element, for which WebKitGTK
auto-registers its own MPRIS player (org.webkit.*) alongside the app's
souvlaki one. On Linux desktops that list every player, now-playing then
showed twice during radio (issue #1048: one "psysonic", one "Psysonic").

Disable the WebKit media session at main-window setup (enable-media-session,
set by GObject property name since the pinned binding has no typed setter,
guarded by find_property) so souvlaki stays the single now-playing source;
radio metadata still reaches it via mpris_set_metadata. The navigator.media
Session push in useRadioMprisSync is kept as a fallback in case a WebKitGTK
version still registers the player, so issue #816 cannot regress.

* docs(changelog): MPRIS radio duplication fix (PR #1069)
2026-06-11 23:58:01 +02:00
Psychotoxical 3de7b57cc5 Fullscreen player polish + Discord Rich Presence fixes (#1068)
* fix(fullscreen-player): drop track-number prefix from title, stop clipping descenders

The big title showed a zero-padded queue position ("11. ") before the
song name; remove it (the position still lives in the top bar). Also bump
line-height 1.05 -> 1.25 so overflow:hidden no longer crops descenders
(g, j, p, q, y) at 46px.

* feat(settings): clarify built-in Discord RP vs official Navidrome plugin

Add a bold notice atop the Discord Rich Presence block explaining it is
the built-in integration, and that users wanting the official Navidrome
Discord RP plugin should leave it off and enable "Show in Now Playing"
instead. Add a matching bold note to the Now Playing setting. New i18n
keys discordRichPresenceNotice / nowPlayingPluginNote across all 5 locales.

* fix(discord-rp): use HTTPS cover URL instead of local file:// path

Discord Rich Presence images are fetched by Discord's servers, so
large_image must be a key or an https:// URL they can reach. Since the
cover pipeline moved to an on-disk webp cache, coverArtUrlForDiscord
returned a file://.../800.webp path on cache hit, which Discord cannot
load and silently falls back to the app icon. Always hand it the
getCoverArt URL; MPRIS keeps the local file:// path unchanged.

* i18n(settings): fill discordRichPresenceNotice + nowPlayingPluginNote for es, nb, ro, ru

The two new Discord-RP notice keys had only landed in 5 of the 9 locales;
complete the set so no locale falls back to English.

* docs(changelog): fullscreen title + Discord RP fixes (PR #1068)
2026-06-11 23:34:58 +02:00
Psychotoxical 1a82376f8c feat(music-network): unified scrobble & enrichment framework (replaces hard-wired Last.fm) (#1066)
* feat(music-network): core domain types and wire contracts

Foundation for the Music Network framework: provider-agnostic domain
types, capability model, typed errors, and account shapes under
src/music-network/core, plus the ScrobbleWire / EnrichmentWire /
PresetManifest / AuthStrategy contracts. No runtime wiring yet.

* feat(music-network): generic audioscrobbler/listenbrainz/maloja transports

Generalize the Rust remote layer for the Music Network framework. Add
provider-agnostic transports parameterized by base_url:

- audioscrobbler_request: Audioscrobbler v2 with caller-supplied endpoint
  (Last.fm, Libre.fm, Rocksky, custom GNU FM, Maloja compat share it)
- listenbrainz_request: Token-header JSON (direct + Maloja LB compat)
- maloja_request: native /apis/mlj_1 JSON

lastfm_request stays as a thin transition delegate against the fixed
host; it is removed once the framework owns all call sites. Wiremock
tests cover audioscrobbler_request with a custom base_url and API-error
mapping.

* feat(music-network): audioscrobbler wire with last.fm + libre.fm presets

Add the Audioscrobbler v2 wire, the behavioural successor to the legacy
src/api/lastfm.ts, implementing the full EnrichmentWire surface (scrobble,
now playing, love/unlove, loved sync, similar artists, track/artist stats,
top lists, recent tracks, user profile, urls).

- client.ts: transport wrapper over audioscrobbler_request, classifying
  failures into MusicNetworkError without touching any store
- sign.ts: TS mirror of the api_sig base-string ordering rule (unit-tested)
- auth/tokenPoll.ts: browser token-poll connect flow as a reusable strategy
- presets/lastfm.ts, presets/librefm.ts: bundled, enrichment-capable,
  token-poll presets (both endpoints verified live)

Extends WireContext with profileBase and ConnectContext with authBase so
URL builders and connect flows need no preset lookup.

* feat(music-network): listenbrainz + maloja-native wires, paste-auth presets

Add the scrobble-destination wires and presets:

- ListenBrainz wire (scrobble + now playing via playing_now), backing both
  the direct api.listenbrainz.org preset and the Maloja /apis/listenbrainz
  compat preset (one wire, two presets, differing only by base URL)
- Maloja native wire (/apis/mlj_1/newscrobble, scrobble only — Maloja has
  no now-playing endpoint)
- Shared api_key_only paste-auth strategy (token/key/session-key paste);
  the Audioscrobbler wire now dispatches token-poll vs paste by preset
- Presets: listenbrainz, maloja_listenbrainz, maloja_native, rocksky
  (scrobble-only, session-key paste, bundled keys — verified live), and
  custom_gnufm (token-poll, user-supplied url/key/secret)
- Contracts: ConnectContext.authStrategy, PresetManifest.selfHostedApiSuffix

maloja_compat (the {url}/apis/audioscrobbler mode) is intentionally omitted:
its protocol cannot be verified and is almost certainly the legacy handshake,
not the 2.0 web API; Maloja is covered by the native and ListenBrainz modes.

* feat(music-network): registry, orchestrator, enrichment router + runtime facade

Wire the framework together behind a single facade:

- registry: wireRegistry (WireId -> wire), presetRegistry (the 7 built-in
  presets), registerBuiltinWires (one-time side-effect registration)
- CapabilityProbe: wire probe overlaid by manifest staticCapabilities as the
  final authority (lets two presets on one wire diverge, e.g. Rocksky's
  nowPlaying:false over the Audioscrobbler wire's optimistic yes)
- ScrobbleOrchestrator: best-effort fan-out; flips the per-account
  session-error flag on AUTH_SESSION_INVALID and clears it on next success
- EnrichmentRouter: resolves the single primary to its EnrichmentWire; the
  type guard rejects non-enrichment wires (Maloja/ListenBrainz)
- MusicNetworkRuntime: the only app entry point — accounts, roles, fan-out,
  enrichment, urls, probe. Reads/writes state through the MusicNetworkStore
  port (Phase 5 backs it with the auth store) and a RuntimeHost for side effects
- getMusicNetworkRuntime singleton + index.ts public barrel

Tests cover fan-out, master toggle, capability gating, session-error
flip/clear, primary eligibility, and enrichment routing.

* feat(music-network): auth-store state + lossless legacy migration + runtime bridge

Add the persisted Music Network state to the auth store and wire the runtime,
all additively — nothing existing breaks yet.

- authStoreTypes: musicNetworkAccounts / enrichmentPrimaryId /
  scrobblingMasterEnabled + actions; legacy lastfm* fields kept until Phase 6
- authMusicNetworkActions + defaults/wiring (synchronous localStorage)
- accountPersistence: migrateLegacyLastfm (lossless — preserves session key,
  username and scrobbling preference; fills bundled Last.fm key from the preset;
  sets the migrated account as enrichment primary) + sanitizeAccounts
- authStoreRehydrate: one-shot migration guarded by a sentinel so a later
  disconnect cannot resurrect the account from still-present legacy fields
- musicNetworkBridge: backs the MusicNetworkStore port with the auth store and
  the RuntimeHost with the Tauri shell; initialized in pre-React bootstrap

nowPlayingEnabled stays a global toggle (not a lastfm* field); the Phase 6
playback call-site will gate dispatchNowPlaying on it, preserving behaviour.

* feat(music-network): route playback, enrichment and love through the runtime

Migrate every Last.fm call-site onto the Music Network runtime, preserving
behaviour:

- playback (audioEventHandlers, playTrackAction): scrobble@50% and now-playing
  via dispatchScrobble/dispatchNowPlaying; loved-fetch via isTrackLoved. Now-
  playing follows scrobbling (as Last.fm did), Navidrome now-playing keeps the
  nowPlayingEnabled gate
- enrichment: useArtistSimilarArtists, useNowPlayingFetchers, Statistics, and
  the ArtistDetail similar-artists gate now use the runtime, gated on an
  enrichment primary
- love: PlayerBar, PlayerTrackInfo, all context menus, useNowPlayingStarLove and
  the startup loved-sync route through setTrackLoved / toggleNetworkLove
- player store: lastfmLoved/lastfmLovedCache -> networkLoved/networkLovedCache;
  lastfmActions -> networkLoveActions; loved cache storage renamed with a
  lossless legacy-key fallback
- getMusicNetworkRuntimeOrNull() for best-effort callers so they no-op (not
  throw) before the runtime is initialized

src/api/lastfm.ts and the Integrations UI still use the legacy path; they are
migrated and removed in the next phase.

* feat(music-network): manifest-driven Integrations UI + scrobble batch format

Replace the Last.fm Integrations card with a manifest-driven Music Network
section, and fix Audioscrobbler scrobbling to the batch/array shape.

- settings/musicNetwork/: MusicNetworkSection (master toggle, destination
  cards, enrichment-primary picker, Maloja proxy warning, add-a-service list)
  driven entirely off the preset registry; icon map from PresetManifest.icon
- IntegrationsTab delegates to MusicNetworkSection (Discord/Bandsintown/
  Navidrome now-playing unchanged)
- i18n: musicNetwork.* across all 9 locales, incl. a per-field help hint for
  Rocksky's CLI session-key flow (rocksky login)
- scrobble now uses the documented array form (artist[0]/track[0]/…); the bare
  single form is only tolerated by Last.fm, Rocksky requires the indexed form
- auth-error detection keys off the response message (not the ambiguous numeric
  code) so a Rocksky server-500 no longer flips the account to a reconnect state
- PresetManifest.PresetField gains an optional helpKey

Rocksky's server rejects some non-ASCII track metadata with a 500 — a Rocksky
backend bug; the client call is correct (verified).

* fix(music-network): clearer Integrations layout + scrobble batch fix

Address UI feedback on the Music Network section:
- per-account scrobble toggle moves inside its account block (was a loose
  row between cards — unclear which account it belonged to)
- master toggle and the primary-service picker are now boxed blocks at the top,
  not bare rows
- primary-service copy reworked: 'Primary service' + a line spelling out that
  liked tracks/similar artists/stats come from it while scrobbling still goes
  to all enabled services
- distinct zones separated by dividers (master/primary · connected · add)

Also folds in the verified scrobble fixes: documented array form
(artist[0]/track[0]/…) so Rocksky accepts scrobbles, and auth-error detection
by message rather than the ambiguous numeric code (a Rocksky server-500 no
longer flips the account to a reconnect state). Rocksky session-key field gains
a CLI help hint (rocksky login), all 9 locales.

* feat(music-network): indicator + remove legacy lastfm path

Phase 7b/7c — finish the cutover and delete the old Last.fm path.

- LastfmIndicator -> MusicNetworkIndicator (shows the enrichment primary's
  status, click -> Integrations)
- delete src/api/lastfm.ts; remove Rust lastfm_request (remote.rs + lib.rs)
- remove legacy authStore lastfm* fields, actions and types; delete
  authLastfmActions.ts; rehydrate migration reads the legacy blob via a cast
- migrate the remaining NowPlaying call-sites (NowPlaying.tsx + the now-playing
  fetchers/prewarm/star-love hooks) off lastfmSessionKey/lastfmUsername onto the
  enrichment primary (gate + cache key)
- type imports LastfmTrackInfo/LastfmArtistStats -> music-network TrackStats/
  ArtistStats; drop 8 stale api/lastfm test mocks and the obsolete Last.fm auth
  tests; update settingsTabs + src/CLAUDE.md

No lastfm imports remain outside src/music-network/; lastfm_request removed
(acceptance §12). tsc clean, 1947 frontend tests + remote rust tests green.

* test(music-network): cover scrobble shape + error classification, drop dead i18n keys

Remove the 14 unused legacy scrobble/connection i18n keys across all 9
locales (settings.lfm*/scrobble*, connection.lastfm*); the live love,
profile-link and now-playing keys stay.

Add regression tests for the parity-critical transport logic: the indexed
batch/array scrobble body, the auth-vs-network error classification
(numeric codes collide across providers), and the manifest-overrides-probe
capability merge.

* feat(music-network): provider-agnostic UI + Maloja Audioscrobbler & Koito presets

- de-hardcode the single provider name across every enrichment surface
  (love labels, now-playing badge, stats title); derive it from the
  enrichment primary and interpolate via {{provider}} i18n params
- surface a toast when a paste-auth connect probe fails — a static
  'supported' capability flag no longer masks a runtime probe error
- add the Maloja Audioscrobbler (GNU FM) preset (the third Maloja wire
  mode) and a Koito preset (ListenBrainz-compatible), both data-only
- generalise PRIVACY.md and the scrobbling help entry to the framework
- rename residual lfm* identifiers to network*; strip legacy flat
  lastfm* fields from the persisted blob; neutral transport error prefix
- tests: error classification, scrobble body, capability probe, registry

* fix(music-network): validate paste-auth keys on connect + UI polish

- AudioscrobblerWire.probe now validates an api_key_only session with a
  signed call and reports scrobble:'error' only on a genuine auth failure
  (a scrobble-only service that rejects user.getInfo is not a bad key), so
  an invalid Maloja Audioscrobbler / Rocksky key surfaces a connect toast
  instead of failing silently; WireContext carries the preset authStrategy
- drop the unreachable Statistics empty-state branch and its dead
  lfmNotConnected i18n key; use the useEnrichmentPrimaryLabel hook there
- drive the love-button glyph from the enrichment primary's manifest icon;
  neutral Music Network section icon; remove a dead LastfmIcon import
- tests: paste-auth vs token-poll probe behaviour

* chore(music-network): rename showLastfmSimilar → showNetworkSimilar, refresh stale comments

Post-parity polish: the similar-artists toggle now sources from the generic
enrichment runtime, so rename the lingering lastfm-flavoured identifier; drop
stale 'Mirrors today's LastfmX' doc comments referencing the removed legacy
types, and generalise the scrobble-point comment.

* docs(changelog): Music Network entry (PR #1066)

Co-Authored-By: cucadmuh <49571317+cucadmuh@users.noreply.github.com>

* fix(music-network): bound request timeout on the provider transports

audioscrobbler_request / listenbrainz_request / maloja_request built a
reqwest client with no timeout, so a hung provider left scrobble / probe /
loved-sync promises unresolved. Add a shared provider_http_client() with a
15s timeout, matching the sibling fetch_* commands. Addresses review C3.

* refactor(music-network): dedupe wire transport + no-enrichment helpers

The three provider clients repeated the same invoke -> classify-error ->
MusicNetworkError boilerplate, and the three probe() bodies repeated the
"mark every enrichment capability no" loop. Extract
wires/shared/invokeTransport() (each wire keeps its own arg shape + auth
rule) and markNoEnrichment() in core/capabilities.ts. Addresses review C4.

* refactor(music-network): drop write-only malojaWireMode dead state

malojaWireMode was written on connect but never read — the wire is resolved
by wireId and the Maloja base URL by the preset's selfHostedApiSuffix.
Remove the field, the MalojaWireMode type, the malojaWireModeFor helper,
the AccountPatch entry, the PresetField union member, and the export.
Addresses review C1.

* refactor(music-network): one useEnrichmentPrimary hook, drop lastfm fallback

The enrichment-primary lookup (accounts.find by enrichmentPrimaryId) was
duplicated across two hooks and inlined in the indicator and both
context-menu builders, two of them with a hardcoded 'lastfm' icon fallback.
Add one music-network/ui/useEnrichmentPrimary() returning
{account,label,icon}|null; useEnrichmentPrimaryLabel/Icon delegate to it and
the indicator + context menus consume it directly. Icon fallback is the
neutral 'custom' glyph, never a provider (provider-agnostic, §7.3).
Addresses review C2.

---------

Co-authored-by: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
2026-06-11 22:56:30 +02:00
‮Artem ea304357ca fix(favorites): reflect player-bar song star in track lists (#1063)
* fix(favorites): reflect player-bar song star in track lists

Liking a song from the player bar / fullscreen / shortcuts wrote only to
the session `starredOverrides` map, which onStarSuccess deleted once the
server sync resolved. List views (AlbumDetail, Favorites, RandomMix,
playlists) seed their starred state from a one-shot fetch and reflect
later changes only by merging that override, so the row reverted the
instant the sync completed — the like never stuck in the list. Toggling
from a list row also updated the row's own local state, which is why that
direction already worked.

Keep the star override as the durable session source of truth (stop
deleting it on success); the in-memory Track / queue-cache patches stay.
The override is unpersisted and superseded by the next toggle, so it never
diverges from the server. Ratings keep their existing clear-on-success
behavior.
2026-06-11 16:07:02 +03:00
cucadmuh 90452a8f8c fix(whats-new): allow plugin-fs write into AppData cache dir (#1062)
* fix(whats-new): allow plugin-fs write into AppData cache dir

mkdir for release-notes/ succeeded but write_text_file to nested
paths was denied without fs:allow-app-write-recursive — RC/stable
re-fetched whats-new.md on every launch with an empty cache folder.

* docs: CHANGELOG PR #1062 for release-notes cache write
2026-06-11 11:02:51 +03:00
cucadmuh c503226b0a fix(library): stop genre-tags migration gate flashing on every launch (#1061)
runGenreTagsPhase entered the blocking 'inspecting' phase before awaiting
the inspect IPC, so the migration modal briefly appeared on every startup
once the one-time genre backfill was already complete. Inspect first without
a blocking phase and only switch to 'running' when work is actually needed.
2026-06-11 10:43:58 +03:00
cucadmuh 5cd01c90ac fix(library): multi-genre local index with track_genre and backfill (#1059)
* fix(library): multi-genre local index with track_genre and backfill

Restore atomic genre browse, filters, and counts via track_genre:
OpenSubsonic genres[] first with Navidrome-default split fallback, sync
write path, read-path query switches, blocking startup backfill with
progress, and v12 repair migration for DBs that recorded legacy 002–011.
TS fallback adds genreTagsFor and migration gate i18n across locales.

* fix(library): address multi-genre review — robust TS genres and scope join

genreTagsFor routes raw genres through parseItemGenres (single-object
Subsonic quirk and bare strings). Library-scoped genre browse/counts join
track for raw_json library_id fallback. Statistics keeps empty-genre bucket.

* docs: CHANGELOG and credits for multi-genre local index (PR #1059)

* docs(changelog): credit HiveMind on Discord for multi-genre report (PR #1059)
2026-06-11 01:02:35 +03:00