Commit Graph
100 Commits
Author SHA1 Message Date
Afonso Jorge Ramos 008bb31579 feat(qobuz): let an embedder choose the bundle cache directory
`cache_path` asks `dirs::cache_dir()` where the token cache belongs, so the
location comes from process-global ambient state and a host that already
knows where its cache goes has no way to say so.

That is fine on the desktop. It is not fine everywhere `dirs` resolves: on
Android it takes the XDG implementation, so the path is `$XDG_CACHE_HOME`
or `$HOME/.cache`, and an app process has neither. The cache then either has
nowhere to go at all, taking the existing "No cache dir available" branch,
or names somewhere the process cannot write, so the write fails and is
logged. Either way the tokens never persist and every launch pays a full
live extraction of a ~7 MB bundle.js.

`QobuzClient::with_cache_dir` takes the root instead. `QobuzClient::new`,
`load_cached_bundle`, `extract_and_cache_bundle_tokens` and
`refresh_bundle_if_changed` all keep their signatures and their behaviour:
passing no directory still resolves through `dirs`. The `qbz/` namespace is
kept inside whatever root is supplied, so the file has the same name and
shape wherever it lands.
2026-08-21 00:58:08 +02:00
Afonso Jorge Ramos 5bebbf0d91 fix(qobuz): replace the bundle token cache atomically
`save_cached_bundle` wrote in place with `std::fs::write`, which truncates
the file before the first byte of the replacement is written. Any kill in
that window leaves an empty or half-written `bundle_tokens.json`, which
parses as nothing, so the tokens are gone and the next launch pays a full
live extraction: a ~7 MB bundle.js fetch behind a 45s timeout, up to three
attempts.

The window is not rare. `refresh_bundle_if_changed` rewrites the whole file
on every launch that finds the version unchanged, purely to touch
`fetched_at`, so a warm cache is destroyed and rebuilt each time the app
starts.

The bytes now land in a sibling temp file that is flushed and renamed over
the target, so a reader sees either the previous cache or the new one. The
replacement is owner-only where writing in place left it at whatever the
umask allowed; these are app-level values scraped from a public bundle
rather than user credentials, so that is tidiness rather than a fix for a
vulnerability.
2026-08-21 00:53:06 +02:00
Afonso Jorge Ramos cd0dd4dc0d docs: simplify macOS installation guidance 2026-08-20 23:19:48 +02:00
Afonso Jorge Ramos 977f5eefac docs(macos): document signed builds and Homebrew cask 2026-08-20 19:55:34 +02:00
Afonso Jorge Ramos 88e9354b80 test(offline-cache): make the two path guards able to fail
the_layout_is_the_same_one_persist_wrote_to compared BundleLayout::new against
the layout persist_bundle returned, which persist_bundle had built from that
same pure function: a value compared with itself. It stayed green when
persist_bundle was made to write under the wrong track id. It now asserts the
derived paths point at files that exist, and fails under that sabotage.

resolution_looks_under_the_current_root_not_the_recorded_path matched only the
root prefix in the error message, so a resolution that found the right root by
the wrong subdirectory, or the right subdirectory for the wrong track, passed.
It now matches the whole derived path, and fails when resolution is pointed at
a sibling subdirectory under the same root.

Also corrects the doc on load_cmaf_bundle: offline_root_path was described as
only locating the secret vault's install UUID, which was true before this
branch and is now the opposite of the truth. It is what locates the bundle,
which is the most load-bearing argument in the function.
2026-08-12 12:45:00 +02:00
Afonso Jorge Ramos 7814d4d2d5 fix(secrets): keep the unit tests off the OS keyring
Asking for real keyring backends made the four round-trip tests write a genuine
login-Keychain item, and macOS binds an item's ACL to the exact binary that
created it. So every run after the first, with any source edit in between, was
a new binary against an existing item, and the OS blocked all four tests on an
authorization dialog that a headless run can never answer. Reproduced: four
tests hanging past 60s, where a fresh keychain passes in 0.39s.

The comment claiming the sandboxed tempdir guaranteed a fresh state was the
thing that hid it. try_open_keyring ignores storage_dir entirely and looks the
entry up by service name alone, so the tempdir bought nothing.

Backend::kdf_only pins them to the path they were always documented as taking.
What they assert, wrap/unwrap round-trips and tamper detection, is identical on
either backend, so nothing is lost. survives_a_restart.rs still exercises the
real backend, which is where it belongs: persistence across processes is what
that test is about.

Also names Linux's backend here instead of inheriting it. qbz-credentials
enables async-secret-service and Cargo unifies features, which holds for every
shipping build but makes this crate's correctness depend on a sibling: built on
its own, cargo tree showed keyring with no secret-service, i.e. the same silent
mock this PR exists to remove. Verified: secret-service v4.0.0 on Linux,
security-framework on macOS, and still zero keyring on iOS and Android.
2026-08-12 03:21:26 +02:00
Afonso Jorge Ramos 99ba4aa48e test(secrets): prove a wrapped secret survives a process restart
The property this crate exists for is the one no in-process test could
show. A single process wraps and unwraps happily against a master key
that never left memory, which is exactly what keyring's mock hands back —
so the whole suite stayed green while every offline download lost its
content key on the next launch.

The test re-executes the test binary: wrap in one child, unwrap in
another. It asserts persistence and not which backend provided it, so the
KDF fallback passes it too and a runner without secret-service is a pass
rather than a flake, while the mock fails everywhere.

The existing job is Linux-only, which is where these crates are least
likely to break — both bugs it now guards shipped on macOS while Linux
was fine. Adds a narrow macOS job for the two platform-sensitive crates:
no ALSA/JACK/D-Bus, no UI graph, a couple of minutes on a 10x runner.
2026-08-11 17:08:53 +02:00
Afonso Jorge Ramos 934a83fdac fix(secrets): enable the real keyring backends on macOS and Windows
keyring 3.x ships no `default` feature contents, and every platform arm
reads `not(feature = "...-native")` -> `pub use mock as default`. Naming
no features therefore selected the in-memory mock on macOS and Windows,
whose writes succeed and whose master key dies with the process, while
`Backend::new` still reported `BackendKind::Keyring`.

The consequence is that every offline track's content key becomes
undecryptable on the next launch, so playback treats it as a cache miss
and silently re-streams what the user had already downloaded.

Linux is left off the list because qbz-credentials already enables
`async-secret-service` and Cargo unifies features; naming
`sync-secret-service` here would trip keyring's own compile_error for
enabling both halves of a store.

Proven with a probe wrapping in one process and unwrapping in another,
on the same machine, changing only this line:
  before: backend = Keyring, UNWRAP FAILED: cipher error: aead::Error
  after:  backend = Keyring, UNWRAPPED OK
2026-08-11 17:00:26 +02:00
Afonso Jorge Ramos 3238244de5 fix(offline-cache): locate a CMAF bundle under the root the cache is open at
Playback rebuilt the bundle layout from the absolute paths recorded in
the row. Those agree with reality until the application directory moves,
and iOS moves it: an app's data container is reassigned on reinstall, and
can change on restore or migration. Every stored path then names a
directory that no longer exists while the bundle sits untouched under the
new one, so every download reports "Downloaded copy could not be opened"
with the bytes still on disk.

Deriving is safe because the index lives inside the root
(<root>/index.db), so a row can only ever describe a bundle beneath that
same root, and persist_bundle writes nowhere else. Removal and eviction
already resolve this way; this brings playback in line with them.
2026-08-11 13:23:21 +02:00
Afonso Jorge Ramos af7fd2a545 fix(secrets): take the KDF path on mobile instead of keyring's mock backend 2026-08-11 01:36:01 +02:00
Afonso Jorge Ramos e7d931fcc8 feat(mixtape): make local and Plex resolution optional features 2026-08-10 19:19:58 +02:00
Afonso Jorge Ramos 4e3638866e build(deps): bump lofty to 0.24
Every 0.23.x release of lofty is yanked on crates.io, so the version
requirement only resolves from the committed Cargo.lock. A fresh resolve
of qbz-library or qbz-offline-cache as a dependency fails outright.

0.24.0 needs no code changes across the 19 call sites.
2026-08-04 00:53:12 +02:00
Afonso Jorge Ramos 3ea0a3119d Merge pull request #85 from vicrodh/pre-release
[pull] pre-release from vicrodh:pre-release
2026-07-31 10:15:40 +02:00
Afonso Jorge Ramos 84b3c9f970 fix(theme): update registry count for Catppuccin variants 2026-07-16 01:20:53 +02:00
Afonso Jorge Ramos 4e37b1370b fix(settings): make the theme-list filter swap its icon and narrow the list
The All/Dark/Light filter next to the theme dropdown did nothing: every
branch of the icon ternary rendered disc.svg, and the Rust `theme-filter`
handler was cosmetic (persisted the value but never filtered the list).

Add distinct sun-moon/moon/sun icons and a luminance-based filter-aware
theme API (filtered_dropdown_themes/labels + index helpers), then wire
startup seeding, the filter cycle, and theme selection through it. `All`
is a proven passthrough of the previous behaviour; Dark/Light narrow the
dropdown by the registry's `is_light` flag.
2026-07-16 01:20:44 +02:00
Afonso Jorge Ramos 96672a459d fix(log): stop logging Last.fm auth token prefixes
Request-token material must never appear in support log bundles.
2026-07-14 13:33:04 +02:00
Afonso Jorge Ramos ec69bce17e feat(ui): open album from grid card title click 2026-07-13 16:45:42 +02:00
Afonso Jorge Ramos b4450965df fix(player): surface stream feeder failures to BufferWriter
CMAF and remote download tasks now error the streaming buffer on failure
so readers do not hang waiting for complete/error forever.
2026-07-13 16:16:14 +02:00
Afonso Jorge Ramos 4ce075beaa docs: tombstone Remote Control OpenAPI for 2.0 Slint
The LAN REST/SSE server is not in the shipping binary; fix license/repo
metadata and mark the document historical while retaining path schemas.
2026-07-13 16:13:05 +02:00
Afonso Jorge Ramos c9b9aea118 fix(scrobble): skip tracks shorter than 30 seconds 2026-07-13 16:12:06 +02:00
Afonso Jorge Ramos 68317f5a34 fix(dsd): treat demux I/O errors as sticky failures, not clean EOF
Only Ok(0) ends the stream cleanly; mid-file read errors set io_error.
2026-07-13 16:11:02 +02:00
Afonso Jorge Ramos 21c4817fcf fix(player): bump play generation on stop, DSD, and streaming
Non-play_track entry points must supersede in-flight play_track so a
slow fetch cannot clobber stop or a later source.
2026-07-13 13:50:06 +02:00
Afonso Jorge Ramos 61df66c529 fix(offline): purge only allowlisted cache layout directories
Stop recursive-deleting arbitrary siblings of the offline root so a
mis-set cache path cannot wipe unrelated folders on a shared mount.
2026-07-13 13:49:55 +02:00
Afonso Jorge Ramos 542ee8050f fix(player): clear streaming loaded state on streaming setup failures
Buffer timeout, engine creation failure, and incremental source failure
no longer leave has_loaded_audio true with no engine; each records a
user-visible stream error.
2026-07-13 13:49:34 +02:00
Afonso Jorge Ramos e153673acf fix(cmaf): fail init parse when raw_data length exceeds payload
Do not advance the cursor by an unclamped raw_len after a truncated slice.
2026-07-13 13:48:40 +02:00
Afonso Jorge Ramos 39dccd8b01 fix(credentials): tighten permissions of existing secret files on load 2026-07-13 13:48:30 +02:00
Afonso Jorge Ramos c1bb5b3dc2 fix(audio): fail closed when ALSA exclusive rate is not exact
Read back the hardware rate after hw_params for PCM, DoP, and native DSD
and refuse silent nearest-rate mismatches.
2026-07-13 13:47:43 +02:00
Afonso Jorge Ramos e1c34005e0 fix(credentials): write secret files with mode 0o600 on Unix
Salt, machine-id fallback, encrypted credentials, and OAuth token files
must not inherit a permissive umask.
2026-07-13 13:47:18 +02:00
Afonso Jorge Ramos 0ff3fc7f40 fix(scrobble): host delay gate in qbz-app for unit testing
Extract scrobble_delay_secs so cargo test -p qbz-app covers unknown
duration without compiling the Slint binary.
2026-07-13 00:08:18 +02:00
Afonso Jorge Ramos 41daffdf71 fix(scrobble): do not scrobble when track duration is unknown
duration_secs == 0 used to arm an immediate scrobble; skip delayed
scrobble instead while still allowing now-playing when online.
2026-07-13 00:01:11 +02:00
Afonso Jorge Ramos 7e824bd65a fix(offline): place download size validator at module scope 2026-07-12 23:57:18 +02:00
Afonso Jorge Ramos f507b5c214 fix(offline): reject truncated downloads when Content-Length is known
Fail closed on short bodies and empty payloads so partial files never
become complete cache entries.
2026-07-12 23:56:59 +02:00
Afonso Jorge Ramos 19b1c2bead fix(cmaf): fail closed on invalid session-key seed hex
Reject odd length and non-hex digits instead of zero-filling bad nibbles.
2026-07-12 23:33:14 +02:00
Afonso Jorge Ramos a0bf1328d6 fix(cast): use CSPRNG media path tokens and redact URIs in logs
Replace RandomState-based tokens with getrandom, avoid logging full
media base URLs, and redact /audio/<token>/… in DLNA info logs.
2026-07-11 23:01:05 +02:00
Afonso Jorge Ramos 3dc54a751d fix(player): stop DoP writer on ALSA write failure
Match the PCM ALSA path: on write_dop_i32 Err, stop the writer thread
instead of logging and advancing position (which desyncs DoP markers).
2026-07-11 23:01:05 +02:00
Afonso Jorge Ramos 101490043d fix(player): cancel superseded play_track completions
Bump a play generation on each play intent so a slow CMAF/legacy fetch
cannot apply AudioCommand::Play after the user has already started another
track. play_data still bumps generation for standalone offline/cache calls.
2026-07-11 23:01:04 +02:00
Afonso Jorge Ramos 12a278fc28 fix(player): clear playing state when seek rebuild fails
After current_engine.take(), seek error paths used to return with no
engine while is_playing stayed true. Abort now sets stream_error and
stops the playing flag so the UI cannot show silent "playing".
2026-07-11 23:01:04 +02:00
Afonso Jorge Ramos 66e6efeffd fix(log): redact secrets on stderr and stop Last.fm body dumps
TeeLogger no longer forwards the original Record to env_logger (which
bypassed redaction). Register restored OAuth tokens for literal scrubbing,
and never debug-log the auth.getSession response body.
2026-07-11 23:01:04 +02:00
Afonso Jorge Ramos acc033915e fix(queue): keep current_index track on clear(true)
truncate(1) always preserved tracks[0], so Clear queue mid-album left the
wrong now-playing row. Keep the track at current_index and remap history
by id so removed rows do not leave stale indices.
2026-07-11 23:01:04 +02:00
Afonso Jorge Ramos 54da577d56 ci(test-crates): align workflow with release CI conventions
Match naming, permissions, concurrency, mold linker, and cache save-if
policy (write only from pre-release) used by the Slint release jobs.
2026-07-10 01:02:37 +02:00
Afonso Jorge Ramos 7a600a60f4 ci: add workspace cargo test job (exclude Slint UI graph)
Run unit/doc tests for the shipping crates workspace on PRs without
compiling qbz-ui. Fix three green-blockers: local playlist schema for
tests, Track.work fixture, and a stale ListenBrainz doctest.
2026-07-10 00:52:05 +02:00
Afonso Jorge Ramos 8f1f287191 fix(settings): hide title bar section on macOS 2026-07-08 17:24:38 +02:00
Afonso Jorge Ramos 032021de44 feat(lyrics): drag region on sidebar header and selectable lyrics panel 2026-05-13 20:42:27 +01:00
Afonso Jorge Ramos e54c5de08b feat(views): drag regions on top-bars across remaining views 2026-05-13 20:40:43 +01:00
Afonso Jorge Ramos 47f3a9f335 feat(views): drag regions on home, library, and favorites top-bars 2026-05-13 20:40:39 +01:00
Afonso Jorge Ramos 897f6ac862 feat(detail-views): drag regions and selectable metadata blocks 2026-05-13 20:40:34 +01:00
Afonso Jorge Ramos eb57c70ef9 feat(modals): mark info modals and legal notice selectable 2026-05-13 20:40:29 +01:00
Afonso Jorge Ramos 418bf371e8 refactor(styles): drop now-redundant local user-select: none 2026-05-13 20:40:23 +01:00
Afonso Jorge Ramos a34e418530 feat(sidebar): drag window from aside top padding band on macos 2026-05-13 20:39:58 +01:00
Afonso Jorge Ramos 69d7ee0212 refactor(layout): remove macos overlay drag and main-content padding 2026-05-13 20:39:57 +01:00
Afonso Jorge Ramos 5610861ed8 refactor(titlebar): drag on spacer divs not full bar 2026-05-13 20:39:56 +01:00
Afonso Jorge Ramos c1c151ee25 refactor(css): user-select none on body with .selectable opt-in 2026-05-13 20:39:50 +01:00
Afonso Jorge Ramos 5865c16624 docs(player): explain set_stream_error(false) message swallow
Clearing the error flag also drops any pending stream_error_message.
A fast record_stream_error -> set_stream_error(false) -> polling-tick
sequence will silently swallow the toast — that is the intent (a
recovered error is not user-actionable), but it surprises future
readers, so document it inline.
2026-05-13 18:07:54 +01:00
Afonso Jorge Ramos 9231ea2bd4 fix(mixtapes): replace click-swallowing backdrop with document click-outside 2026-05-13 17:54:48 +01:00
Afonso Jorge Ramos b3b0fce535 fix(purchases): replace click-swallowing backdrop with document click-outside 2026-05-13 17:54:48 +01:00
Afonso Jorge Ramos 3382a36ede fix(mixtape-collection): replace click-swallowing backdrop with document click-outside 2026-05-13 17:54:48 +01:00
Afonso Jorge Ramos 29598747fb fix(collections): replace click-swallowing backdrop with document click-outside 2026-05-13 17:54:48 +01:00
Afonso Jorge Ramos ff11dea869 fix(label): drop tracks context menu backdrop in favor of click-outside listener 2026-05-13 17:54:48 +01:00
Afonso Jorge Ramos ddf0ed4175 fix(artist-detail): consolidate section sort menus and drop tracks context backdrop 2026-05-13 17:54:48 +01:00
Afonso Jorge Ramos 126dff1297 fix(library): consolidate toolbar dropdowns into single openMenu state 2026-05-13 17:54:47 +01:00
Afonso Jorge Ramos 7fccf9bd01 fix(favorites): consolidate toolbar and section dropdowns into single openMenu state 2026-05-13 17:54:47 +01:00
Afonso Jorge Ramos fc56fb4b2f feat(genre-filter): make isOpen bindable for parent dropdown coordination 2026-05-13 17:54:08 +01:00
Afonso Jorge Ramos 84dcd7ebbe perf(+page): guard lyrics state writes against subscriber churn
subscribeLyrics fires per rAF tick (~60Hz with extrapolation). The
previous version wrote all six $state slots unconditionally, which
re-triggered the lyricsSidebarLines $derived (a .map allocating N
objects per call) every tick even though lyricsLines itself only
changes per-track. Adding reference/value guards short-circuits the
fan-out for unchanged fields — the per-tick path now only writes
lyricsActiveProgress (and sometimes lyricsActiveIndex).
2026-05-13 14:31:19 +01:00
Afonso Jorge Ramos 640dead393 fix(lyricsStore): claim rAF handle pre-tick and notify on every change
Two related fixes to the active-line rAF loop:

- The previous guard reassigned updateRafHandle *after* updateActiveLine
  returned. Any listener that synchronously re-entered
  startActiveLineUpdates during notifyListeners() saw a null handle and
  scheduled a second parallel rAF chain. Now the handle is claimed
  before the work runs, so re-entry is a no-op.

- Drops the inter-tick progress threshold. With playerStore extrapolating
  audio time between backend events, every rAF tick has a freshly-changed
  progress value. Threshold-throttling would skip the edge samples where
  the snap-to-1 and indexChanged transitions need to land — re-introducing
  the "starts midway"/"ends before 100%" bugs. Downstream consumers
  equality-guard their $state writes, so fan-out only fires on real
  changes.
2026-05-13 14:31:11 +01:00
Afonso Jorge Ramos 6166584ee8 perf(playerStore): extrapolate audio time between backend position events
The Rust backend emits playback:state at 250ms cadence. Frontend
getCurrentTime() was returning that staircase value — for the karaoke
rAF loop running at 60Hz, every 15 frames got the same answer, then a
250ms jump. This caused two visible artifacts:

- Active line appears to "start midway" when detection of the new index
  lags by up to 250ms past the line's timeMs (audio is already that far
  into the line by the time we see the boundary).
- Active line gets demoted before its highlight reaches the end, because
  the last in-line notification reports progress ~0.92 (one tick before
  bound) and the next backend tick has already crossed into the next
  line — the snap-to-1 never gets a chance to fire.

Audio position is wall-clock-based on the backend (see the 250ms emit
loop comment in lib.rs); we now do the same math in JS: anchor
(positionSecs, performance.now()) on every event-driven update, then
return anchor + elapsed while playing. Cap extrapolation at +1s so a
stalled backend doesn't run away. Existing 33 playerStore tests pass
unchanged — they read currentTime directly, not getCurrentTime().
2026-05-13 14:31:03 +01:00
Afonso Jorge Ramos 64511c4fa2 feat(lyrics): segment active-line gradient by visual line via pretext
Pre-pretext, the karaoke gradient applied to a single span shared the same
horizontal cut across every wrapped visual line — line 2 appeared partially
sung when only line 1's worth of vocals had played. Pretext lays out the
active lyric into visual lines; each renders as its own block-level span
with its own 0->100% gradient, so progress fills sequentially.

Also adds race/perf safeguards layered on top:
- Cancellation token on tick().then(layoutActiveLine) so rapid activeIndex
  changes (seek-scrub, fast songs) don't queue stale microtasks against
  the current DOM.
- ResizeObserver coalesces via rAF and ignores sub-pixel width deltas, so
  window-drag and the active-line scale animation don't trigger Pretext
  re-measurement every callback.
- segmentBounds prefix array memoizes cumulative widths, dropping per-
  segment progress lookup from O(i) to O(1) inside the each-block.
- EMA writes equality-guard measuredBlockMs/Delta and lookaheadEnabled so
  Svelte signals don't fan out no-op updates 60 times per second.
- Lookahead clamped at 1/30 and disabled on backward dp so a coarse audio
  jump or in-line seek can't poison the visual lead.
- measuredBlockMs seed lowered (>=20ms, dur * 0.01) since rAF-rate data
  converges the EMA within 2-3 ticks; the prior 80ms floor visibly lagged
  the gradient on the first frame of every new line.
- LyricsLine type unified from store (single source of truth).
2026-05-13 14:30:54 +01:00
Afonso Jorge Ramos 57f8307934 feat(ui): toast on audio:init-failed event
Listen for the backend audio:init-failed event and show a user-facing
error toast (8s) with the backend-provided cause. Mirrors the
audio:device-missing listener pattern alongside it. Surfaces the
silence when macOS CoreAudio init fails, instead of leaving the user
guessing why playback didn't start.
2026-05-13 13:09:35 +01:00
Afonso Jorge Ramos a251849094 i18n(toast): add audioInitFailed translation key
Add localized strings for the new backend audio init failure toast
(en, de, es, fr, pt). Reason placeholder receives the backend's
already user-friendly message (e.g. "macOS audio device changed its
sample rate during stream open...").
2026-05-13 13:09:31 +01:00
Afonso Jorge Ramos 6d91ec0e02 feat(audio): emit audio:init-failed Tauri event on backend init failure
The playback-event polling loop now drains
SharedState::take_stream_error_message and emits a Tauri
audio:init-failed event with the user-readable cause. Drain semantics
guarantee the toast fires exactly once per error instead of every
tick of the polling loop.
2026-05-13 13:09:26 +01:00
Afonso Jorge Ramos 04e17cc449 feat(player): expose user-readable stream_error_message channel
Pairs the existing stream_error AtomicBool with an Option<String> that
the audio thread can populate when a stream init failure has a
meaningful cause to surface to the user (currently: the macOS
CoreAudio rate-mismatch path).

set_stream_error(false) also clears the message; record_stream_error
sets both atomically; take_stream_error_message drains so the UI
fires its notification exactly once per error.
2026-05-13 13:09:18 +01:00
Afonso Jorge Ramos a7b40bf6bb perf(player): cache CoreAudio nominal rate query for 750ms
evaluate_stream_recreate runs on every track change on the audio
thread, and each call issued two HAL syscalls
(resolve_output_device_id + get_nominal_sample_rate). Cache the result
per-thread for 750ms — long enough to absorb the bulk of repeats
within an album, short enough to notice a system rate change.

Cache invalidates on device-name change, so switching the output
device still triggers a fresh query.
2026-05-13 09:23:39 +01:00
Afonso Jorge Ramos b5db0241a9 fix(LyricsLines): make line-text block to expand background-clip paint area 2026-05-12 18:24:37 +01:00
Afonso Jorge Ramos f9a9d103cb fix(LyricsLines): let EMA accept rAF-rate block measurements 2026-05-12 18:01:52 +01:00
Afonso Jorge Ramos f4f8fcf9bf fix(LyricsLines): use activeProgress as karaoke floor, lookahead as bonus 2026-05-12 17:58:16 +01:00
Afonso Jorge Ramos 4de657e2cd feat(LyricsLines): JS-driven karaoke with measured block-interval and lookahead 2026-05-12 17:57:35 +01:00
Afonso Jorge Ramos 6d25100133 refactor(LyricsLines): single-div template with class-toggle transitions 2026-05-12 17:55:30 +01:00
Afonso Jorge Ramos 4d426a80cd fix(lyrics): snap line progress to 1 when at least 99% through 2026-05-12 17:54:01 +01:00
Afonso Jorge Ramos 959f656866 refactor(lyrics): notify listeners on every progress change 2026-05-12 17:53:49 +01:00
Afonso Jorge Ramos 13d1d6919a perf(lyrics): drive active-line updates via requestAnimationFrame 2026-05-12 17:53:35 +01:00
Afonso Jorge Ramos a5100a329a perf(page): memoize lyrics sidebar lines projection 2026-05-12 17:53:06 +01:00
Afonso Jorge Ramos 9aacf1d7eb feat(lyrics): parse LRC gap markers and use endMs for line bounds 2026-05-12 17:52:48 +01:00
Afonso Jorge Ramos fdeb544c63 fix(lyrics): correct active-line color on macOS WebKit 2026-05-12 15:10:09 +01:00
Afonso Jorge Ramos 4fd85b5b2f fix(ci): suffix macOS .app.tar.gz bundles with arch to fix auto-updater 2026-05-11 16:17:07 +02:00
Afonso Jorge Ramos 1edde8a28a fix(audio): rephrase CoreAudio rate-mismatch errors for end users
The error strings flow up through stream_error and end up in user-
visible logs. Replace the internal-shaped messages ("opened at XHz
while device nominal rate is YHz") with text that names the cause,
the remediation, and the reason the fallback path is refused.
2026-05-11 15:19:30 +02:00
Afonso Jorge Ramos 9e632386b9 fix(audio): retry CoreAudio shared-mode open on rate mismatch
CPAL caches device configs and can briefly return a stale sample rate
right after CoreAudio changes the device nominal rate. Before this
change, opening at the stale rate failed the post-open validation and
returned Err, leaving the user with no audio on the first track after
a rate change.

Drop the bad sink, re-resolve the device, re-query nominal rate and
try once more with a 50ms backoff. Two attempts in total. Turns the
racy window into transparent recovery.
2026-05-11 15:16:46 +02:00
Afonso Jorge Ramos c408ea73cb test(player): cover compute_needs_new_stream decision rule
Extract the boolean decision out of evaluate_stream_recreate into a
pure function so the truth table can be tested without faking a
MixerDeviceSink or AudioSettings mutex. Eight cases cover the matrix:
no stream, unchanged format, format change with and without each
bit-perfect backend, and the CoreAudio shared-mode rate-mismatch path.
2026-05-11 15:15:57 +02:00
Afonso Jorge Ramos ac60113cdd refactor(player): extract StreamRecreateDecision helper
The rate-mismatch detection block was duplicated between the Play and
Stream handlers, and each handler acquired thread_settings.lock() three
times in a row. Extract into one helper that reads settings once and
returns every flag the recreate decision needs.

Also drops a dead else-branch that logged "Creating initial audio
stream" — unreachable inside if stream_opt.is_some() — and the
redundant info-log that re-announced the rate-mismatch reason at
recreate time (the warn-level log inside the helper already says
"Recreating stream to avoid wrong-speed playback").
2026-05-11 15:14:54 +02:00
Afonso Jorge Ramos 3b34a734b4 refactor(player): rename current_sample_rate to current_track_sample_rate
The field tracks the decoded source format, not the output stream's
hardware rate. The names are now explicit so future readers don't
conflate the two — on macOS shared mode these can legitimately differ
since Rodio resamples internally.
2026-05-11 15:13:29 +02:00
Afonso Jorge Ramos ac627d432b fix(home): unify sticky tab header to avoid pop-in animation 2026-05-09 17:52:39 +02:00
Afonso Jorge Ramos 8ed62676c2 fix(titlebar): tune macos drag band to 32px 2026-05-09 17:45:46 +02:00
Afonso Jorge Ramos ac5fc25944 fix(titlebar): match macos drag band height to sidebar top padding 2026-05-09 17:37:04 +02:00
Afonso Jorge Ramos be67fffb28 feat(titlebar): expose hide-title-bar toggle on macOS 2026-05-09 17:36:28 +02:00
Afonso Jorge Ramos 61bd1eb940 feat(settings): expose nav and search titlebar toggles on macOS 2026-05-09 17:23:40 +02:00
Afonso Jorge Ramos 3cb54dd06b style(sidebar): drop dead macos no-titlebar padding rule 2026-05-09 17:23:37 +02:00
Afonso Jorge Ramos fd6464fdf3 refactor(macos): drop overlay drag region superseded by qbz titlebar 2026-05-09 17:23:32 +02:00
Afonso Jorge Ramos 0ee2e429c8 feat(titlebar): mount qbz titlebar on macOS with traffic-light reservation 2026-05-09 17:23:23 +02:00
Afonso Jorge Ramos 88e072a4ac fix(macos): harden CoreAudio exclusive stream handling
Address review feedback from the macOS Exclusive Mode PR.

Pin the resolved CoreAudio output device before acquiring Hog Mode so
System Default cannot drift between Hog Mode acquisition, sample-rate
switching, and stream creation.

Fall back to software volume when CoreAudio hardware volume is unavailable,
which keeps the volume slider usable on knob-only DACs.

Snapshot and restore CoreAudio hardware volume around Exclusive Mode so
leaving Exclusive Mode does not leave the output device at QBZ's last
hardware volume.

Refuse shared-mode fallback when macOS Exclusive Mode stream creation
fails, so the app does not silently play in non-exclusive mode while the UI
still reports Exclusive Mode.

Clean up Linux unused-variable warnings from the macOS-only guard plumbing.
2026-05-07 09:36:20 -05:00
Afonso Jorge Ramos e93fcaec99 fix(audio): defer host-stream drop on Stop to reuse device across track changes 2026-05-07 02:32:39 +02:00
Afonso Jorge Ramos 468a421e26 fix(audio): gate 50ms post-stop sleep behind cfg(target_os = "linux") 2026-05-05 17:11:28 +02:00
Afonso Jorge Ramos 1e3f5ab981 fix(audio): populate macOS output device picker 2026-05-04 00:31:21 +02:00