Commit Graph

1161 Commits

Author SHA1 Message Date
Frank Stellmacher af1b9661f5 fix(orbit): three interlocking guest playback bugs (#525)
* fix(orbit): guest short-circuits queue-exhaustion fallback paths

When a guest's local queue runs out (single-track queue from `syncToHost`
empties on `audio:ended`), the player walks the standard fallback chain
in `next()`: radio top-up → infinite-queue → stop. The infinite-queue
branch builds a 6-track queue and calls `playTrack`, which trips
`orbitBulkGuard` and pops a "Add 6 tracks to the Orbit queue?" modal.
Hitting Cancel leaves playback frozen; "Add them all" injects unrelated
tracks into the host's shared queue.

In an active Orbit guest session the host owns the queue. Skip the
fallback paths entirely and just stop — the next `useOrbitGuest` pull
tick will sync to whatever the host advanced to.

Bonus side-effect: kills the deferred-promise race where a
`buildInfiniteQueueCandidates().then(...)` from a guest's track end
could resolve *after* a Catch Up replaced the queue and pop the modal
a second time against the now-current 1-track queue.

* fix(orbit): treat natural track-end as not-diverged in guest sync

When a guest's track ended naturally before the host advanced, the
divergence-detection branch read `player.isPlaying === false` and
classified it as the user manually paused — so it refused to load the
host's next track. The guest sat silent until they clicked Catch Up.

`handleAudioEnded` keeps `currentTrack` pinned to the just-ended track
and resets `currentTime` to 0, while a real manual pause leaves
`currentTime` somewhere mid-track. Use the 0-position discriminator to
classify natural-end as not-diverged so the host's new track loads.

Confirmed via the captured guest log buffer:
  18:43:08.598 [track-change] host: VJkV5… → 6i6RP… BUT guest diverged
                              (player.isPlaying=false ≠ last.isPlaying=true)
  — guest stuck for ~33s until Catch Up was pressed.

* fix(orbit): Catch Up polls until engine is ready before seeking

The 400 ms blind setTimeout in `onCatchUp` was too short for an
HTTP-streamed cold-start on high-latency links. If the audio engine
wasn't ready by then, `seek(fraction)` silently no-oped and playback
started at 0:00, making Catch Up effectively useless on exactly the
slow links where it's needed. Captured log shows a Catch Up bringing
the guest to posSec=30, then 5 s later the guest was at posSec=6
(playback restarted from the head).

Replace with the same poll-until-ready pattern `syncToHost` already
uses: check every 100 ms, fire the seek as soon as the engine reports
playing, fall back to a blind apply at the 4 s deadline.

* docs(changelog): add orbit guest playback fixes entry

* fix(orbit): debounce Catch Up button + match bar item height

Two follow-on UX fixes after PR #525's three primary bugs landed:

1. **Debounce visibility.** Drift is computed from an asymmetric signal:
   guest's `currentTime` updates in coarse ~5 s chunks, while host's
   position is extrapolated linearly via `(nowMs - posAt)`. Even on a
   perfectly-synced session the diff swings ±5 s every tick, so the
   button flickered in and out continuously. Show only after drift has
   stayed over the 3 s threshold for ≥ 3 s of wall clock — measurement
   noise is filtered out, real sustained drift still surfaces in time.

2. **Match neighbour height.** The button was 32 px tall against 26 px
   for the other action buttons (.orbit-bar__settings) so every flicker
   shifted the entire bar height. Set `height: 26 px` and tighten the
   padding/font so the layout is stable regardless of visibility.

* fix(orbit): tighten queue-extension lockout + reliable initial-sync seek

Two follow-on fixes after the 4-bug umbrella:

**1. Local queue-extension paths fully off during Orbit.**
Phase check broadened from `active` to cover `starting` / `joining` /
`active` so a fetch-then-join race can't pop the bulk-add modal *after*
the join. The proactive infinite-queue topper inside `next()` (which
fires when ≤ 2 auto-tracks remain ahead) is now also gated, plus each
async `.then()` callback in the radio + infinite-queue paths re-checks
at resolution time. A `playTrack(... 6-track queue ...)` after the user
joined Orbit was the path that re-triggered the "Add 5 tracks?" modal
on a freshly-joined guest.

**2. `syncToHost` only seeks once the engine reports playing.**
The previous 2 s deadline-fallback applied the seek even when the
engine hadn't started, where the seek silently no-ops and the track
plays from 0:00. Symptom: clicking Catch Up makes the song "jump 50 %
forward" — that's the seek finally landing because the engine is now
ready, the initial-sync seek had already failed silently. New deadline
is 5 s, and on timeout we return `false` so the outer pull tick keeps
`lastAppliedRef` null and the 500 ms fast-poll retries.

* fix(orbit): double-click play button + hide preview during session

Two cucadmuh-flagged gaps:

**1. Double-click on the inline play button now reaches the orbit-add
   path.** The album-track row's onDoubleClick already routes to
   `addTrackToOrbit` when in Orbit, but the inline play button stopped
   propagation on click — so clicking it twice just fired the "double-
   click to add" hint toast and never touched the orbit queue. Add an
   onDoubleClick on the button itself that delegates to the parent's
   `onDoubleClickSong`.

**2. Track preview is suppressed during an Orbit session.** Preview
   shares the Rust audio engine with the shared playback, so starting
   one as a guest yanks the host's track out from under everyone. A new
   `[data-orbit-active]` attribute on `<html>` (set whenever role is
   host/guest and phase is starting/joining/active) hides every
   preview button via a single CSS rule, and `previewStore.startPreview`
   short-circuits as a defensive guard for keyboard shortcuts and any
   programmatic callers.
2026-05-09 21:50:52 +02:00
Frank Stellmacher a702a5dd5b feat(orbit): in-app diagnostics popover with copyable event log (#524)
* feat(orbit): in-app diagnostics popover with copyable event log

Multiple users on Discord report Orbit guests stopping after the first
song with no errors anywhere — Settings → Debug → Export Logs is too
buried for non-technical reporters, and the relevant code branches
have no logging at all (silent fail). This adds a one-click "Copy log"
path right inside the Orbit session bar.

The new Activity-icon button next to Help opens a popover with:

- Live mini-display: role, host vs. guest track id + position, drift,
  age of the host's last state write — all updating once a second.
- Scrolling event log textarea fed by an in-memory ring (200 events).
- Copy + Clear buttons. Copy formats `[ISO] [scope] body` lines and
  drops them on the clipboard — paste straight into a Discord report.

Instrumentation lands at the previously-silent decision points:

- Guest pull tick: full snapshot of host vs. guest state on every read.
- Each branch of the divergence detection in `useOrbitGuest.ts` logs
  which path it took and why (initial / track-change-followed /
  track-change-diverged / play-pause-flip), making the
  "stuck after first song" symptom diagnosable from the buffer alone.
- Host pushes log track id, isPlaying, queue length, guest count.

Events are also bridged to the existing `frontend_debug_log` Tauri
command when Settings → Logging is on Debug, so power users still get
the same data in `psysonic-logs-*.log` for offline triage.

i18n: full `orbit.diag.*` namespace in all eight locales. EN + DE are
native; ES / FR / NB / NL / RU / ZH are first-pass and may want a
polish from native speakers later.

* docs(changelog): add orbit diagnostics popover entry
2026-05-09 21:49:17 +02:00
Psychotoxical acadc1be34 Merge remote-tracking branch 'origin/main' into refactor/backend-pilot 2026-05-09 18:53:02 +02:00
Frank Stellmacher 874b0c67ae fix(context-menu): drop inline z-index that hid menus under floating player (#522)
* fix(context-menu): drop inline z-index that hid menus under floating player

The main context menu wrapper carried an inline `zIndex: 999` that
overrode the `.context-menu { z-index: 10000 }` stylesheet rule. The
floating player bar sits at z-index 1000, so when a menu opened near
the bottom of the screen — long enough or anchored low — the player
bar covered it.

Removing the inline override lets the stylesheet rule (10000) win, so
the menu always paints on top of the floating bar. Submenus inherit
the same stacking context so they follow the parent menu.

Closes #521.

* docs(changelog): add context-menu floating-player z-index fix entry
2026-05-09 18:52:37 +02:00
Psychotoxical 0d29a09455 Merge remote-tracking branch 'origin/main' into refactor/backend-pilot 2026-05-09 18:12:01 +02:00
Frank Stellmacher fec513b629 fix(home): swap Because-you-listened rail to AlbumRow under 696 px (#520)
* fix(home): swap Because-you-listened rail to AlbumRow under 696 px

The hero-style BecauseCards are tuned for full-rail widths (3 cards at
1052 px+, 2 cards at 696-1051 px). Below that the cards stretched
full-width with a fixed 160 px cover stuck on the left and centred text
floating in a wide empty area — looked like three over-sized banners
stacked vertically instead of a compact recommendation rail.

A `ResizeObserver` on the rail wrapper now watches the container width
and below 696 px renders a standard `AlbumRow` (which is already
perf-tuned for narrow rails: artwork budget, viewport windowing, scroll
paging). Wide layouts keep the unchanged hero card layout, so the
mainstage view at full width is identical to before.

* docs(changelog): add Because-you-listened narrow-layout fix entry
2026-05-09 18:11:22 +02:00
Psychotoxical b8fb1ca8fc Merge remote-tracking branch 'origin/main' into refactor/backend-pilot 2026-05-09 17:46:28 +02:00
Frank Stellmacher 268086ac74 docs(issue-template): label Nix install option as "flakes" (#519)
The bug report form's install-source dropdown listed "Cachix / Nix",
but Cachix is just a binary cache — users actually install the app
via the Nix flake. Relabel to "flakes" so the dropdown reflects the
mechanism people pick.
2026-05-09 17:44:44 +02:00
Psychotoxical 37c19237f3 fix(refactor): restore Windows build on backend-pilot
- Re-export `build_mini_player_window` from `lib_commands::ui` so the
  Windows-only pre-create call in `lib.rs` resolves through the
  `use lib_commands::*` glob (was lost during the M5 split).
- Gate the `is_tiling_wm` import in `ui::mini` and its re-export in
  `lib_commands::sync` behind `cfg(target_os = "linux")`, the only
  platform that actually consults it.
2026-05-09 17:38:40 +02:00
Psychotoxical eff7bd036e Merge remote-tracking branch 'origin/main' into refactor/backend-pilot 2026-05-09 17:08:20 +02:00
Frank Stellmacher acc367207a chore: add GitHub issue forms (bug + feature + config) (#517)
Three files under .github/ISSUE_TEMPLATE/:

- bug_report.yml      Required: summary, repro steps, expected/actual,
                      version, OS. Optional: install source dropdown
                      (AUR / .deb / .rpm / Cachix / .dmg / .msi /
                      from-source), Subsonic server type + version,
                      logs (with inline how-to), screenshots, anything
                      else. Auto-labels: bug, triage.

- feature_request.yml Required: use case (described as workflow, not
                      solution). Optional: proposed solution,
                      alternatives, anything else. Auto-labels:
                      enhancement, triage.

- config.yml          blank_issues_enabled: false; redirects general
                      questions to Discord, Telegram for chat,
                      AUR page for packaging issues.

Net effect: incoming issues land with the info needed to triage
without back-and-forth, and casual support questions are routed off
the issue tracker.
2026-05-09 16:49:04 +02:00
Psychotoxical 97f06459f3 refactor: move analysis admin commands into psysonic-analysis (M6/7)
Reframed M6 from "extract psysonic-commands" to "place each domain's
Tauri commands in its own domain crate" — the original psysonic-commands
proposal would have been a thin shell with no clear domain ownership
because each prior milestone already kept its own commands inline:

  audio_*_commands           in psysonic-audio
  cache/sync commands        in psysonic-syncfs
  navidrome/discord/etc      in psysonic-integration

The leftover analysis admin commands (7 of them) logically belong to
the analysis domain. So they move there:

  src/lib_commands/app_api/analysis.rs   →
    crates/psysonic-analysis/src/commands.rs

  WaveformCachePayload + LoudnessCachePayload  moved out of top-crate
                                               lib.rs into commands.rs

PlaybackQueryHandle gets a second closure (`should_defer_backfill`) so
analysis_enqueue_seed_from_url can ask "is a ranged playback already
going to seed this track?" without depending on psysonic-audio.

Top crate keeps the shell-flavored commands (window/tray/mini-player,
greet/exit_app, mpris/global-shortcuts/check_dir_accessible, perf,
cli_bridge) for M7 to clean up.

Behaviour preserving. Cargo check + clippy --workspace clean.
2026-05-09 14:00:33 +02:00
Psychotoxical 98d8ea6353 refactor: extract psysonic-integration crate (M5/7)
Moves all outbound external-service bridges out of the top crate into a
new psysonic-integration crate.

  crates/psysonic-integration/
    src/discord.rs                     Rich Presence + iTunes artwork
    src/navidrome/{client,covers,...}  Native REST API admin (5 modules)
    src/remote.rs                      radio-browser, last.fm, ICY meta,
                                       generic CORS proxy (fetch_url_bytes
                                       / fetch_json_url / resolve_stream_url)
    src/bandsintown.rs                 events for an artist
    src/lib.rs                         module declarations + macro re-exports

Top crate keeps `crate::discord` working via
`pub use psysonic_integration::discord;`.

invoke_handler! in lib.rs uses full deepest paths
(`psysonic_integration::navidrome::users::nd_list_users`, etc.) so
Tauri's `__cmd__*` magic macros resolve at the right module — same
pattern audio + syncfs already use.

Cross-crate refs migrated:
  crate::subsonic_wire_user_agent → psysonic_core::user_agent::*
  pub(crate) → pub                  (cross-crate visibility)

Behaviour preserving. Cargo check + clippy --workspace clean.
2026-05-09 13:51:28 +02:00
Psychotoxical 9417d522f3 refactor: extract psysonic-syncfs crate (M4/7)
Moves all on-disk cache + device-sync code out of the top crate:

  crates/psysonic-syncfs/                      new lib crate
    src/cache/{offline,hot,downloads,fs_utils} unchanged behaviour
    src/sync/{batch,device}                    (no tray.rs — that's UI)
    src/file_transfer.rs                       shared HTTP helpers
    src/lib.rs                                 + DownloadSemaphore type
                                               + sync_cancel_flags fn

The shell crate keeps `lib_commands/sync/tray.rs` (OS tray icon — UI
concern, will move to shell-tauri at M7) but drops the rest of
`lib_commands/cache/` and the syncfs sibling files.

Tauri command quirk surfaced and resolved: `#[tauri::command]` puts its
`__cmd__*` and `__tauri_command_name_*` helper macros at the *exact*
module of the function, and `pub use` doesn't carry them across module
boundaries. invoke_handler! in lib.rs now references each syncfs
command via its full deepest path
(`psysonic_syncfs::cache::offline::download_track_offline`, etc.) so
Tauri's macros resolve at the right scope. Same approach the audio
crate already uses (`audio::commands::audio_play`).

Cross-crate ref migrations applied via batch sed:
  crate::audio::*               → psysonic_audio::*
  crate::analysis_runtime::*    → psysonic_analysis::analysis_runtime::*
  crate::analysis_cache::*      → psysonic_analysis::analysis_cache::*
  crate::subsonic_wire_user_agent → psysonic_core::user_agent::*
  super::super::file_transfer:: → crate::file_transfer::

Behaviour preserving. Cargo check + clippy --workspace clean.
2026-05-09 13:45:53 +02:00
Psychotoxical 41e75663f1 refactor: extract psysonic-audio crate (M3/7)
Moves all audio playback code (Symphonia decode, rodio output, HTTP
streaming, gapless, previews, and the seven stream/ source-type
submodules from the prior split) out of the top crate into a new
psysonic-audio crate.

  crates/psysonic-audio/                  new lib crate, depends on
                                          psysonic-core + psysonic-analysis
    src/{engine,helpers,decode,…}.rs      flattened layout (no more
                                          extra audio/ namespace level)
    src/stream/                           seven submodules from M0
    src/lib.rs                            re-exports macros from
                                          psysonic-core and the public
                                          API surface

The audio↔analysis edges identified in the dep survey are now real
crate deps (audio depends on analysis directly: AnalysisCache reads,
recommended_gain_for_target, submit_analysis_cpu_seed). Only the
analysis→audio back-edge goes through the PlaybackQueryHandle port
registered in M2.

Cross-crate ref migrations applied via batch sed:
  crate::audio::*               → crate::*       (intra-crate)
  crate::analysis_cache::*      → psysonic_analysis::analysis_cache::*
  crate::submit_analysis_cpu_seed → psysonic_analysis::analysis_runtime::*
  crate::subsonic_wire_user_agent → psysonic_core::user_agent::*

Top crate keeps `crate::audio::*` paths working via
`pub use psysonic_audio as audio;` — lib_commands/cli callers untouched.
`stop_audio_engine` (mac process-exit cleanup) moved into the audio
crate as `pub fn stop_audio_engine` since it reaches AudioEngine
internals; tray.rs now re-exports the moved fn.

Two small visibility promotions in engine.rs:
  pub(crate) fn analysis_track_id_is_current_playback   → pub
  pub(crate) fn ranged_loudness_backfill_should_defer    → pub

Behaviour preserving. Cargo check + clippy --workspace clean.
2026-05-09 13:33:44 +02:00
Psychotoxical ff456dd823 refactor: extract psysonic-analysis crate (M2/7)
Moves analysis_cache + analysis_runtime out of the top crate into a new
psysonic-analysis crate, plus the runtime user-agent facade into
psysonic-core. The audio↔analysis dependency cycle is broken via a
PlaybackQueryHandle port registered as Tauri State.

  crates/psysonic-analysis/                new lib crate
    src/analysis_cache/{mod,store,compute} unchanged behaviour
    src/analysis_runtime.rs                + enqueue_analysis_seed (was
                                           in lib_commands/cache/offline)

  crates/psysonic-core/src/
    user_agent.rs                          subsonic_wire_user_agent +
                                           runtime/default helpers
                                           (was in top lib.rs)
    ports.rs                               PlaybackQueryHandle: closure
                                           wrapper, not Arc<dyn Trait>,
                                           so existing State<AudioEngine>
                                           callsites stay unchanged

The shell setup hook registers the real PlaybackQueryHandle once the
AppHandle is available; the closure captures it and re-resolves
AudioEngine via try_state at each call.

Top crate keeps `crate::analysis_cache`, `crate::analysis_runtime`,
`crate::subsonic_wire_user_agent`, and `crate::submit_analysis_cpu_seed`
working via re-exports — no audio/lib_commands callsite needed editing.

Behaviour preserving. Cargo check + clippy --workspace clean (only
pre-existing warnings carry over).
2026-05-09 13:25:04 +02:00
Psychotoxical 7718ac3ee5 refactor: introduce cargo workspace + psysonic-core crate (M1/7)
First milestone of the workspace crate split. Sets up the workspace
skeleton and extracts shared logging + cross-crate port traits into a
new psysonic-core crate.

  src-tauri/Cargo.toml            now defines [workspace]; top package
                                  becomes the workspace root.
  crates/psysonic-core/           new lib crate, no Tauri-handler code:
    src/logging.rs                full logging facade (was src/logging.rs)
    src/ports.rs                  PlaybackQuery + AnalysisOrchestrator
                                  trait declarations (no impls yet)

The top crate re-exports `psysonic_core::logging` and the
`app_eprintln!` / `app_deprintln!` macros so every existing
`crate::logging::*` and `crate::app_eprintln!` callsite keeps working
unchanged.

Port traits intentionally take `tauri::AppHandle` — psysonic-core is a
workspace-internal crate, so depending on Tauri here is a feature, not
a leak. Implementers will register themselves as
`Arc<dyn PlaybackQuery>` / `Arc<dyn AnalysisOrchestrator>` Tauri State
in M2/M3.

Behaviour preserving. Cargo check + clippy --workspace clean.
2026-05-09 13:06:28 +02:00
Psychotoxical 9455879044 refactor(audio): split stream.rs into stream/ submodules
Pure file-move refactor: 1000-LOC stream.rs → stream/ directory with
seven cohesive submodules and explicit pub(crate) re-exports:

  icy.rs         (109)  ICY metadata state machine + parser
  reader.rs      (110)  AudioStreamReader (ringbuf → Read shim)
  local_file.rs   (32)  LocalFileSource (psysonic-local://)
  ranged_http.rs (382)  RangedHttpSource + ranged_download_task
  radio.rs       (187)  RadioLiveState + radio_download_task
  track_stream.rs (182) track_download_task (one-shot)
  mod.rs          (48)  re-exports + shared tuning constants

Source-type lifecycles are now isolated: each MediaSource impl lives
next to its download task. External callers (radio_commands,
transport_commands, play_input, engine, helpers) keep their existing
`super::stream::{...}` paths via the mod.rs re-exports — no caller
edits required.

Behaviour preserving. Cargo check + clippy clean (only the pre-existing
"too many arguments" warning on track_download_task carries over).
2026-05-09 12:55:43 +02:00
Psychotoxical 0992113269 fix(audio): suppress audio:error toast for superseded plays
Rapid skipping while a track is in initial preparation produced a
misleading "Couldn't play track — skipping" toast for the abandoned
track. Sequence: audio_play(A) reaches build_source_from_play_input;
user skips → audio_play(B) bumps gen; A's RangedHttpSource::read sees
the gen mismatch and returns Ok(0); Symphonia's probe interprets Ok(0)
as EOF → "ranged-stream: format probe failed: end of stream";
audio_play(A)'s map_err emits audio:error unconditionally → toast
appears even though playback already moved on.

Gate the emit on a generation check: if the global generation has
already moved past this play's gen, the failure is supersedion, not a
real codec error — log it but do not surface the toast.

Pre-existing bug, identical code path on main (commands.rs:600). Noticed
on refactor/backend-pilot during cucadmuh's skip-test session because
the live-test pattern hit the narrow probe-window race repeatedly.

The diagnostic logs added in the previous commit should make the next
occurrence's cause visible regardless of whether the toast fires.

Frontend impact:
- handleAudioError no longer fires on supersedion → no toast, no
  setState({ isPlaying: false }), no queued next() call.
- The audio_play IPC promise still rejects with the same error string
  (preserved as the .map_err return value); the JS .catch is already
  generation-guarded in playerStore so this is a no-op there.
2026-05-09 01:48:38 +02:00
Psychotoxical ff4271181c diag(audio): trace ranged-stream supersedion + abort paths
Skipping tracks while a RangedHttpSource is still in initial probe leaves
no diagnostic trail today: RangedHttpSource::read returns Ok(0) on
gen-mismatch (silent), ranged_download_task drops out the same way, and
the `dl done` summary only fires under app_deprintln so release users
never see partial/aborted downloads either.

Add focused logs at the bail points without changing behaviour:

- RangedHttpSource::read — log on each Ok(0) return that isn't the normal
  pos>=total_size EOF (superseded before/during wait, download done with
  no bytes ahead of cursor). Symphonia stops reading after Ok(0), so at
  most one log per source, no spam.
- ranged_download_task — log on the gen-mismatch bail with track id +
  gen transition + downloaded/total bytes so we can tell "user skipped
  while downloading" apart from "stream stalled".
- track_download_task — same gen-mismatch log for the legacy
  non-seekable path (consistency with ranged).
- dl-done summary — split into release-visible `[stream] ranged dl
  ABORTED: …` (downloaded < total_size) vs the existing dev-only
  `[stream] dl done` for full completions.
- audio_play — log on both supersedion-bail points around
  select_play_input so a silently-ending audio_play call leaves a trace.

No semantics changed: every existing return / store / branch is intact.
Pure additive logging to make the next reproduction of cucadmuh's
ranged-stream toast diagnosable.
2026-05-09 01:47:02 +02:00
Psychotoxical 176382e0b6 refactor(lib_commands): drop super::* + glob in app_api + lib_commands root
Hotspot G final slice — replace cascade-imports in app_api/{core,
analysis,integration,remote,platform}.rs with explicit per-symbol
imports, and convert app_api/mod.rs from broad `pub(crate) use foo::*`
to a per-command list of every Tauri handler.

The remaining glob re-exports live only in lib_commands/mod.rs itself
— and those are deliberate: they flatten the explicitly-listed Tauri
commands one more level so lib.rs's `use lib_commands::*` keeps the
invoke_handler shape unchanged. Each level above is now explicit.

file_transfer.rs's `super::subsonic_wire_user_agent()` becomes
`crate::subsonic_wire_user_agent()` since the lib_commands cascade
no longer pulls lib.rs symbols into super scope.

`grep -rn 'use super::\*' src/lib_commands/` returns zero hits.
Behaviour-preserving.
2026-05-08 16:45:46 +02:00
Psychotoxical 1ff8bdd8c7 refactor(lib_commands): drop super::* + glob re-export in sync + lib.rs
Hotspot G next slice — replace cascade-imports in lib_commands/sync/
{device,batch,tray}.rs with explicit per-symbol imports + per-command
re-exports.

sync/mod.rs lists each Tauri command from device / batch / tray
explicitly, plus the three internal helpers consumed by lib.rs and
ui/mini.rs (is_tiling_wm, stop_audio_engine, try_build_tray_icon).
The previous broad `pub(crate) use device::*` etc. is gone.

Inside the .rs files: each `use super::*` is replaced with what the
file actually needs — `super::device::{TrackSyncInfo, ...}` for batch,
`crate::tray_runtime::{Tray*}` + `crate::audio` + `super::super::ui::
{PAUSE_RENDERING_JS, RESUME_RENDERING_JS}` for tray, etc.

Cleanup in lib.rs: drop the now-unused tauri menu/tray imports
(MenuBuilder/MenuItemBuilder/PredefinedMenuItem/TrayIcon*/MouseButton*)
— tray.rs owns those now. Drop `Ordering` (no longer used at lib.rs
scope). Drop `pub(crate) use file_transfer::*;` from
lib_commands/mod.rs (file_transfer is now imported by direct path
where needed: `super::super::file_transfer::*`).
2026-05-08 16:32:04 +02:00
Psychotoxical 409d8fa964 refactor(lib_commands): drop super::* + glob re-export in ui + cache
Hotspot G first slice — replace the cascade-import pattern in
lib_commands/ui/ and lib_commands/cache/ with explicit per-symbol
imports + per-command re-exports.

ui/mod.rs no longer reaches `pub(crate) use mini::*; pub(crate) use
bandsintown::*;` — instead it explicitly re-exports the 8 mini-player
Tauri commands + the 3 internal helpers consumed by lib.rs +
sync/tray.rs (PAUSE_RENDERING_JS, RESUME_RENDERING_JS,
persist_mini_pos_throttled), and fetch_bandsintown_events.

cache/mod.rs likewise lists each Tauri command from offline / hot /
downloads explicitly. The only cross-crate "internal" re-export is
`enqueue_analysis_seed` which analysis_runtime depends on.

Inside the .rs files: every `use super::*` is replaced with the
specific imports actually needed (super::offline::..., crate::audio,
crate::analysis_runtime::..., tauri::Manager, etc.).

Behaviour-preserving — no Tauri command name changed, no runtime
behaviour shift. Just removes the leak-everything-everywhere import
graph that cucadmuh's policy doc forbids.
2026-05-08 16:18:51 +02:00
Psychotoxical f43ab94cc1 refactor(app_api): split navidrome.rs into 5-way module directory
Convert app_api/navidrome.rs (655 LOC monolith) into navidrome/ with
five focused submodules + a thin mod.rs:

- client.rs (106 LOC) — auth + retry + http client (navidrome_token,
  NdLoginResult, nd_err, nd_retry, nd_http_client). Internal-only,
  not re-exported at crate scope.
- covers.rs (107 LOC) — 4 multipart image-upload commands
  (upload_playlist_cover / upload_radio_cover / upload_artist_image /
  delete_radio_cover).
- users.rs (138 LOC) — login + admin user CRUD (navidrome_login +
  nd_list/create/update/delete_user).
- queries.rs (207 LOC) — songs, role-filtered artists/albums,
  libraries, per-user library assignment, absolute song path.
  Includes the nd_build_filters helper.
- playlists.rs (120 LOC) — playlist CRUD with smart-rules payload
  passthrough (nd_list / create / update / get / delete _playlist).

mod.rs is now ~28 LOC of declarations + per-module re-exports of the
Tauri commands. The cascade `app_api/mod.rs` → `pub(crate) use
navidrome::*` keeps lib.rs invoke_handler registrations unchanged.

Behaviour-preserving — pure file moves with `super::client::*` imports
where the auth/retry helpers are needed.
2026-05-08 15:59:39 +02:00
Psychotoxical 7e8acd86d6 refactor(audio): extract sink swap + crossfade handoff into helper
Pull the atomic sink-swap block (~50 LOC) out of audio_play into
play_input::swap_in_new_sink. The helper takes a SinkSwapInputs
struct (sink, duration_secs, volume, gain_linear, fadeout trigger +
samples, crossfade flag, measured fade seconds) and:

1. Locks state.current, atomically replaces sink + duration + seek/play
   timestamps + volume + replay-gain + fade-out handles, returns the
   old sink + its old fade-out handles.
2. If crossfade is on: stores total fade samples, flips trigger atomic
   on the old TriggeredFadeOut, parks the old sink in fading_out_sink,
   spawns a small task that drops it after `fade_secs + 0.5 s`.
3. If crossfade is off: stops the old sink immediately.

Behaviour-preserving — same lock scope, same atomic ordering, same
cleanup-task lifetime. audio/commands.rs: 600 → 563 LOC. With this
the audio_play body has shrunk from the original ~760 LOC monolith
to a top-level orchestration of named helper calls.
2026-05-08 15:50:07 +02:00
Psychotoxical ed92a77035 refactor(audio): lift PlayInput → BuiltSource dispatch into helper
Pull the 60-LOC match in audio_play that turned a PlayInput into a
fully-wrapped rodio source out of audio_play and into
play_input::build_source_from_play_input. Returns a small PlaybackSource
struct holding both the BuiltSource and a `is_seekable` flag (only
the Streaming variant is non-seekable).

Behaviour preserved verbatim — same build_source / build_streaming_source
calls, same target_rate=0 (no app-level resampling), same
spawn_blocking decoder build for Seekable+Streaming, same error path
(`audio:error` emit + propagate).

audio/commands.rs: 642 → 600 LOC. The remaining audio_play body now
reads top-to-bottom as: ghost guard → preview clear → gapless pre-chain
check → bump generation → reuse-bytes prep → URL pin → format hint →
**select_play_input** → gen check → loudness/RG via gain_inputs →
crossfade prep → **build_source_from_play_input** → stream rate
switching → sink construction + prefill → swap sink → progress task.
2026-05-08 15:45:53 +02:00
Psychotoxical 8f976dc371 refactor(audio): unify loudness/replay-gain prep via TrackGainInputs
audio_play and audio_chain_preload were both reading the same engine
state (target_lufs, norm_mode, pre_analysis_db) and resolving the
loudness cache, then feeding it into compute_gain — but with subtly
different intermediate steps. audio_play split the resolve+post-resolve
into two operations so it could log the cache value; chain_preload
used the bundled `loudness_gain_db_or_startup` wrapper.

Lift the shared logic into `resolve_track_gain_inputs(state, app, url,
logical_id, js_loudness_gain_db) -> TrackGainInputs` in helpers.rs.
The struct returns target_lufs, norm_mode, the cache-loudness value
(for logging), and the post-resolve effective loudness for compute_gain.
Both call sites now use the same helper; behaviour-preserving.

Drops the now-unused `loudness_gain_db_or_startup` (audio_chain_preload
was its only caller). audio/commands.rs: 676 → 642 LOC.
2026-05-08 15:41:19 +02:00
Psychotoxical 7a61d50c42 refactor(audio): extract source selection from audio_play
Pull the ~300 LOC URL → PlayInput dispatch out of audio_play into a
new audio/play_input.rs:
- PlayInput enum (Bytes / SeekableMedia / Streaming)
- PlayInputContext struct holding the precomputed inputs (url, gen,
  duration_hint, format/cache hints, optional reused chained bytes)
- select_play_input() async — handles all four branches: reused
  chained bytes, psysonic-local://, ranged HTTP with format sniff,
  and the legacy non-seekable streaming fallback
- url_format_hint() — the conservative URL→extension allowlist
  helper, used to be inline in audio_play

audio_play is now focused on the orchestration above the source layer:
ghost-command guard, gapless pre-chain detection, bumping generation,
loudness/replay-gain math, fade-in setup, building the actual rodio
Source pipeline, sink swap with crossfade, spawning progress task.

audio/commands.rs: 980 → 676 LOC. play_input.rs: 385 LOC. Behaviour
preservation hinges on identical analysis-seed spawning, format-hint
fallback chain, range-detection logic, and gen-cancel checks — all
moved verbatim. Smoke test focus: psysonic-local playback, manual skip
during ranged HTTP, format-hint-less servers (legacy fallback path),
preload cache hit replay, manual skip onto pre-chained track.
2026-05-08 15:15:30 +02:00
Psychotoxical 2b4014870c refactor(app_api): extract window + WebKitGTK platform tweaks
Lift set_window_decorations + linux_webkit_apply_smooth_scrolling +
set_linux_webkit_smooth_scrolling out of app_api/core.rs into
app_api/platform.rs (~49 LOC). These are platform-tweak Tauri
commands (Linux WebKitGTK settings + Linux native-decoration toggle)
— logically separate from runtime control, telemetry, cli bridge,
or wire-UA setup.

This is the "platform" slice from cucadmuh's plan for splitting
core.rs's mixed concerns. Together with perf and cli_bridge, core.rs
is now down to runtime control (greet, exit_app), logging (3 cmds),
and UA setup — 56 LOC, focused.
2026-05-08 14:22:23 +02:00
Psychotoxical ee7c1de3d6 refactor(app_api): extract cli_bridge wrappers into own submodule
Lift the four cli_publish_* Tauri commands (player_snapshot,
library_list, server_list, search_results) out of app_api/core.rs
into app_api/cli_bridge.rs. Each is a thin pass-through to
`crate::cli::write_*_response` — the renderer-side counterpart to the
file-based IPC layer in cli/exchange.rs.

This is the "cli-bridge" slice from cucadmuh's plan for splitting
core.rs's mixed concerns. Together with the earlier perf split,
core.rs is now down to runtime-control + platform + logging slices —
one possible follow-up but each is small enough to leave as-is for now.

app_api/core.rs: 122 → 99 LOC.
2026-05-08 14:20:11 +02:00
Psychotoxical b5ae0ccf28 refactor(app_api): extract perf telemetry into own submodule
Lift PerformanceCpuSnapshot struct + the Linux /proc/stat parsers
(parse_proc_stat_line / read_total_jiffies / collect_proc_stats) +
the performance_cpu_snapshot Tauri command (~110 LOC) out of
app_api/core.rs into app_api/perf.rs. Self-contained CPU-usage
telemetry; nothing else in app_api references the helpers.

This is the "telemetry" slice cucadmuh's plan called out for splitting
core.rs's mixed runtime/platform/snapshot concerns. Other slices
(cli-bridge, runtime-control, platform window/scrolling) can follow.

app_api/core.rs: 235 → 122 LOC. lib.rs registration unchanged —
performance_cpu_snapshot is re-exported through `pub(crate) use
perf::*` in app_api/mod.rs.
2026-05-08 14:18:32 +02:00
Psychotoxical 3b5bd3f1cc refactor(audio): lift spawn_progress_task into own module
Move the per-generation progress + ended-detection task (~205 LOC)
out of audio/commands.rs into audio/progress_task.rs. The task is now
a sibling submodule that both audio_play (commands.rs) and
audio_play_radio (radio_commands.rs) import as
`super::progress_task::spawn_progress_task`, replacing the previous
`super::commands::spawn_progress_task` cross-import that radio was
using as a workaround.

audio/commands.rs: 1185 → 977 LOC. Cleanup: dropped now-unused
AtomicU32 and AudioCurrent imports from commands.rs.

What's left in commands.rs is now just audio_play (~760 LOC) and
audio_chain_preload (~195 LOC) — the playback orchestrator proper.
Splitting those further is a bigger structural job than file moves.
2026-05-08 14:14:50 +02:00
Psychotoxical e9421e58e3 refactor(audio): extract audio_preload into preload_commands
Pull audio_preload (background fetch + analysis seed for the next
track in the queue) out of audio/commands.rs into
audio/preload_commands.rs (~67 LOC). It's a self-contained
fetch-and-cache flow — distinct from audio_chain_preload (which
constructs the gapless source chain) and audio_play (which starts
playback) so it makes sense to live alongside them rather than inside
the same file.

audio/commands.rs: 1241 → 1185 LOC. lib.rs invoke_handler updated.
2026-05-08 14:05:34 +02:00
Psychotoxical 0fae33f00b refactor(audio): extract transport commands into transport_commands
Pull audio_pause / audio_resume / audio_stop / audio_seek (~210 LOC)
out of audio/commands.rs into audio/transport_commands.rs. They mutate
state.current on an already-running sink and coordinate radio
warm/cold resume — distinct concern from playback startup
(audio_play / audio_chain_preload / audio_preload).

audio/commands.rs: 1447 → 1240 LOC. Imports trimmed: TryLockError,
radio_download_task, RADIO_BUF_CAPACITY are no longer pulled in here;
they followed the transport block to its new home. lib.rs
invoke_handler updated.

The remaining commands.rs is now focused on the playback orchestration
proper (track + chain preload + initial preload + the shared
spawn_progress_task helper).
2026-05-08 14:01:56 +02:00
Psychotoxical 17099d3aee refactor(audio): extract radio playback into radio_commands
Pull audio_play_radio (~165 LOC) out of audio/commands.rs into
audio/radio_commands.rs. Live-radio playback differs from main track
playback: no gapless chain, no seek, no replay-gain, no preload —
collecting it on its own makes both modules easier to reason about.

The shared spawn_progress_task helper (still used by audio_play /
audio_chain_preload / audio_resume in commands.rs and now by radio)
moves from private to `pub(super)` so radio_commands can call it.
A follow-up could lift it into helpers.rs proper.

audio/commands.rs: 1614 → 1447 LOC. lib.rs invoke_handler updated.
Cleanup: dropped now-unused imports (`super::sources::*`,
`RadioLiveState`, `RadioSharedFlags`) from commands.rs.
2026-05-08 13:52:35 +02:00
Psychotoxical e2ca581264 refactor(audio): extract audio-stage settings into mix_commands
Pull the six configuration setters out of audio/commands.rs into
audio/mix_commands.rs (~170 LOC):
- audio_set_volume (with replay-gain-aware ramp)
- audio_update_replay_gain (resolves cache-backed loudness, computes
  gain, ramps sink, emits NormalizationStatePayload)
- audio_set_eq (10-band gains + pre-gain)
- audio_set_crossfade
- audio_set_gapless
- audio_set_normalization

These are pure AudioEngine state mutations + (for normalization) an
ipc emit. They don't drive playback; collecting them in one place
makes commands.rs more focused on the playback orchestrators.

lib.rs invoke_handler updated. audio/commands.rs: 1784 → 1614 LOC.
2026-05-08 13:41:24 +02:00
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