Commit Graph
79 Commits
Author SHA1 Message Date
vicrodh 1503b8236f feat(core): read the Windows memory figures from GlobalMemoryStatusEx
detect_profile fell back to MemoryProfile::from_total_kb(u64::MAX) -- the top
tier -- on any machine without /proc/meminfo, and read_memory_pressure
returned None forever. The prefetch and buffer tiers were therefore pinned at
the top on Windows and the memory watchdog was blind: it continues on None, so
evict_l1_audio_cache and the prefetch halt could never fire at any pressure.

Both halves had to land together. With the old u64::MAX total, a working
pressure probe would have computed roughly 0 per cent available and reported
critical permanently.

ullAvailPhys is the analogue of MemAvailable: what an allocation can take
without going to disk. The Normal-fallback contract is preserved, so a probe
that fails still never throttles the machine. windows-sys 0.61 is already in
the workspace lock via qbz-local-catalog and qbzd, so nothing new is pulled.

Measured on a 32 GB box: 57.9 per cent available, class Normal, which is the
same class the old fallback produced -- the change is the true total and a
pressure figure that exists at all.

cc
2026-08-27 12:44:20 -06:00
vicrodh 09fcdeaa06 feat(audio): WASAPI Exclusive as a selectable backend
AudioBackendType::WasapiExclusive, offered on Windows, plus the selection arm
that opens the exclusive stream before the shared one - the same shape ALSA
Direct has on Linux.

No separate enumeration was needed: 249adc77b already made AudioDevice.id the
WASAPI ENDPOINT ID, which is exactly what WasapiDirectStream::new wants. That
fix turns out to have been the load-bearing one; by friendly name this could
not have addressed a device at all.

Two deliberate fallbacks, both of which prefer a resampled track to silence:

  - No device chosen: there is nothing to open exclusively, so it says so and
    uses the shared stream. Grabbing the default DAC would be deciding for the
    user.
  - The endpoint refuses the rate: warn and fall through. A DAC that will not
    take 192/24 in exclusive still plays it shared.

The mode LED lights EXCL only when a device IS chosen, because with none the
player falls through to shared - a lit EXCL there would be the LED lying,
which is the one thing output_labels must never do.

backendIsWasapi joins the settings document, which is what unhides the
Exclusive-mode and DSD rows that fc0d35e29 gated on it.

229 + 71 + 110 tests pass; five QML audits pass.

cc
2026-08-27 11:19:19 -06:00
vicrodh 77f7840ae7 fix: one hostname resolver, honouring COMPUTERNAME
Three copies read HOSTNAME and then /etc/hostname. Windows has neither, so
every Windows install fell through to the same last-resort word and they
collided on Qobuz Connect. One implementation in qbz-app; HOSTNAME keeps
precedence so Linux behaviour does not move.

qbzd/src/qconnect/transport.rs was a fourth copy the plan did not list, and
the settings_qt.rs one it did list no longer exists on this branch. Both
callers keep their own last-resort word - "Desktop" here, and qbzd's own -
because they are user-visible and mean different things.

qbz-credentials/src/lib.rs:273 also reads HOSTNAME and is deliberately NOT
touched: it is a salt in the KDF fallback chain, not a device label, so
adding COMPUTERNAME there would change the derived key on Windows and make
every already-encrypted credential file undecryptable. That is W17.

cc
2026-08-27 03:46:05 -06:00
vicrodh edcb12b8a1 feat(qt): close migration parity gaps
cx
2026-08-26 11:11:50 -06:00
vicrodh 2bfc2b244c fix(auth): isolate guest and account state
cx
2026-08-26 11:11:27 -06:00
vicrodh 69ed612077 refactor(frontend): retire Slint application
cx
2026-08-26 11:10:18 -06:00
vicrodh aadad1da54 feat(qbz-qt): restore offline and logged-out scrobbling
Port the Tauri immediate and accumulated offline policies into the active Qt fire path, retain independent scrobbler sessions across Qobuz logout behind a default-on opt-out, and flush queued Last.fm/ListenBrainz plays only when the effective policy permits it.

cx
2026-08-25 21:21:37 -06:00
vicrodh a2d0ada427 feat(queue): persist playback history across restarts
cx
2026-08-22 20:59:14 -06:00
vicrodh 63a4f2daf1 feat(qt): the Local Library union, the settings store and the sweep
Three pieces that only mean anything together: rows in the shared cache,
a union that shows them, and settings that decide whether either
happens.

THE UNION. `get_albums_metadata_page` grows a second, INDEPENDENT
attachment. `plex_cache` stays; `remote_cache` is the shared mirror, and
they attach separately on purpose — a missing Plex cache must not cost
the user their Jellyfin rows. `attach_best_effort` is extracted because
the failure mode of a botched ATTACH is a silently local-only library,
and a failure stays non-fatal: refusing to list any albums because one
server is off turns "Jellyfin is unplugged" into "your library is
empty".

The union takes an ENABLED-SOURCES list, not just a path. The mirror
holds every remote source, so the master toggle has to be a filter
rather than a detach: turning Jellyfin off must hide Jellyfin's rows and
leave Subsonic's alone. That predicate is interpolated into SQL, so it
is VALIDATED rather than escaped — only [a-z] survives, an all-invalid
list yields `0` (show nothing, the safe direction), and a test feeds it
`x'; DROP TABLE local_tracks; --`.

The remote group key is `<source>:<album id>`, matching Plex's
`plex:<hash>`. That namespaces two servers using the same album id AND
lets a source claim an album card that arrives with no source word,
which is every card that round-trips through a QML string property —
`recognises` grew that arm and `claim` strips the prefix, because
carrying it inward is how Plex ended up with two album keys only one of
its queries understood (IC-2).

THE SETTINGS. One store for both servers, keyed by a `server` column,
mirroring the shared-cache decision. What each protocol needs differs
and `is_configured` is the one place that knows: Jellyfin needs its
issued token, Subsonic needs a password because it has no session and
re-derives `md5(password + salt)` per request.

Two identifiers are minted once and persisted, and both are
load-bearing. Jellyfin's DeviceId, because re-authenticating under the
same one revokes the previous token — a constant would make two installs
log each other out, a fresh one per launch drops the last run's token.
Subsonic's salt, because rolling it per request makes every cover URL
unique and the artwork cache keys on the URL.

THE SWEEP. In the frontend, where the runtime and the progress UI are,
writing through the cache handle the SOURCE owns so sweep and reads can
never point at different users. Jellyfin gets progress and a delta path
(45.8 s for 4924 rows); Subsonic needs neither (0.81 s for 6678).
`prune_stale` runs only after a COMPLETED full sweep — a delta never
prunes, because it deliberately does not see unchanged rows and would
read every one of them as deleted. `last_sync_at` is stamped only on
success for the same reason.

Verified against the running binary, twice:

  source registry bound: local_tracks=617 plex=connected
    ephemeral_store=yes remote=none configured
  jellyfin: minted a device id for this install
  subsonic: minted a salt for this install

...and on the second launch, zero re-mints and the same values on disk —
which is the property that keeps a Jellyfin session alive.

qbz-qt 428/428, qbz-app 224/224, qbz-library 100/100, qbz-source 88/88.
Release build clean, zero warnings.

cc
2026-08-20 09:20:07 -06:00
vicrodh 730ae6998e feat(qt): windowed View-all artwork, a real per-rail default, Play later, MyQBZ tabs
VIEW-ALL PAGES GOT THE LIBRARY > ALL TREATMENT, in both halves.

PlaylistBrowse windowed nothing: every card mounted, and a View-all of
playlists runs to hundreds, each a PlaylistCard carrying a four-tile collage.
AlbumCollection had solved that for the album pages and this one never got it.
The band is sampled on a timer rather than bound to contentY — a direct binding
re-evaluates every delegate on every scroll frame — and out-of-band cards keep
their footprint as bare Items, so the scroll geometry and the infinite-scroll
trigger never move.

Covers arrived in ONE batch: Rust downloaded every missing cover of the page and
then republished the whole document to attach the paths, so a page had no
artwork until it had all of it, and the republish rebuilt every delegate on
arrival. Both browse pages now ask for what is near the viewport and take the
answers one key at a time through the existing sidebarArtworkWindow ->
libraryArtworkReady seam. `attach_card_art` stays — it is a pure disk-cache
lookup, so anything already downloaded still renders on the first frame with no
request. The two now-dead republish helpers are gone with it.

The AlbumCollection change is ADDITIVE for its nine hosts: those that pass no
`flick` never report, the map stays empty and `artOf` falls through to the
`artPath` they already publish.

ITEMS PER CAROUSEL now has a real default instead of a lie. The first entry read
"All", which was wrong twice over: an "all" in Qobuz terms is what the View-all
page holds, and /discover/index hands each rail a different count anyway —
measured on a live response, new_releases 26, ideal_discography 26, playlists 20,
most_streamed 15, and qobuzissims / album_of_the_week / press_awards 10. No
single label could be honest about all of them, so the default became a real
uniform cap of ten and the entry says so. That is a behaviour change and not
only a label one: the rails that used to arrive at 26 now arrive at 10, and the
select is how a rail gets more. Ten therefore stops being a separate option
because it IS the first one, and the number comes from Rust so the label and the
cap cannot drift. The select also went 82 -> 124 wide: "Default (10)" elided to
"Defaul…" in both the closed control and the popup.

PLAY LATER on the album page's track rows. It was off because
`enqueue_album_track` had only two arms — "next" and "everything else appends" —
so "later" and "queue" both plain-appended and offering both would have been two
menu entries doing one thing. The third arm lands on the manual block's tail via
`add_track_later`, which is the whole distinction, and the QConnect route stops
collapsing "later" into "queue" (route_track_to_peer has had a real later arm
all along). Only then does the entry turn on.

MIXTAPES <-> COLLECTIONS as a tab bar, the Discover/Library pattern where the
tabs stand in for the page heading. They are separate ROUTES sharing one
component, so a tab click navigates rather than flipping a local property: the
sidebar highlight, the back stack and the `kind` Binding then agree on where the
user is, and the page transition is the one every route change gets.

Eight locales. Release build clean, offscreen smoke green, 421/421 and 216/216.

cc
2026-08-19 13:53:30 -06:00
vicrodh 81e172457f fix(qt): migrate playlist_meta, break a router binding loop, zebra the playlist
RECENTLY PLAYED PLAYLISTS STAYED EMPTY THROUGH EIGHT TRACKS, a restart and a
manual refresh — while the plays were sitting in the database the whole time.
Read off the live file rather than reasoned about: `playlist_play_events` held
3, 9 and 10 rows for three playlists, so the context edge was working perfectly;
`playlist_meta` was empty, and its `.schema` said why — no `covers`, no
`own_image`.

`covers` and `own_image` were added by inlining them into the CREATE. `CREATE
TABLE IF NOT EXISTS` does NOTHING when the table already exists, so anyone who
had run the previous build kept the six-column table, every upsert failed on
"no such column: covers", and the rail JOINs meta against events — so it
rendered its empty state with the evidence one table away. The failure was
invisible by construction: swallowed into a warn, and the UI's empty state is
also its correct state for a user who has played nothing.

A brand-new table is only new ONCE. The moment a build runs anywhere its schema
is legacy and every later column is a migration, so the pair moves to the same
idempotent ADD COLUMN the album history already uses, and the upsert's failure
becomes an error rather than a warn. Verified against a copy of the real
database: the columns land and all 22 events survive. A regression test builds
the exact pre-covers schema, records a play into it, migrates, and asserts the
upsert works and the old event still counts — it reproduces the bug without the
fix, and it also asserts a second `init_schema` is a no-op, since every launch
calls it.

The meta is written by `playlist_qt::load`, so the three playlists already in
the events table surface as soon as each is opened once.

BINDING LOOP IN THE ROUTER, and this one was self-inflicted: "QML Loader:
Binding loop detected for property source". `_stampRoute` runs inside the
`source` binding, and it read the last path from a `property string` and then
wrote it — a binding depending on a property it mutates. QML detects the cycle,
warns, and stops re-evaluating, which is a broken router and not merely a noisy
log. The holder becomes an object whose MEMBER is mutated: no change signal, so
the dependency is satisfied once and never fires again.

(The second warning on the same file, the `when` loop on the nav-flyout
`activeTab` Binding, is untouched by any of this work and predates it. Left
alone deliberately — it is a warning on a working feature, and rewriting that
Binding is its own change.)

ZEBRA on the playlist detail's track rows, like the local album page. TrackRow
already owns the stripe, so it is one property.

Release build clean, offscreen smoke green, 421/421 and 216/216.

cc
2026-08-19 09:01:32 -06:00
vicrodh f0d753a751 fix(qt): fade on tab switches, playlist recents artwork, per-rail sizes, wheel
Four defects from the smoke of 26ceeab82.

TAB SWITCHES DID NOT FADE. The page fade hangs off the router's Loader
`source`, and a tab switch inside a view never touches it — so Discover's four
tabs, Library's and Local Library's cut hard while a route change faded, which
is the same event from where the user sits. `activeTab` is the one name every
tabbed view uses for this (the nav-flyout Binding already routes through it),
so one Connections in the router covers all of them; `ignoreUnknownSignals`
leaves the untabbed views alone. No watchdog and no opacity assignment there: a
tab body is built synchronously in the same turn, so `restart()` alone — which
sets the page to `from` — is the whole transition. The `running` guard keeps a
tab stamped right after a route change from restarting that route's fade.

RECENTLY PLAYED PLAYLISTS DREW PLACEHOLDERS. The rail published only
`artwork_url`, taken from the detail page's `cover_url` — and that field is
empty for every playlist WITHOUT a graphic of its own, which is most of them
and is exactly why the detail page falls back to a collage. A local playlist has
no Qobuz graphic at all, so it could never resolve one. The meta store now keeps
the member covers and an own-image flag beside the url, and the card gets both
arms it already knows how to draw: contain-fitted single graphic, or the mosaic.
All of it rides `art_url` and lets `attach_cached` fill the path —
`artwork_qt::cached_path` already classifies http, Plex and on-disk paths, so a
custom cover and a local file resolve through the same pass as a Qobuz url.

ITEMS PER CAROUSEL WAS ONE NUMBER FOR THE WHOLE PAGE. Tauri's was per rail
(`HomeSettingsModal.svelte`), and the global was a simplification that lost the
point of the feature: a 25-wide "New Releases" beside a 10-wide "Recently
Played" is why anyone opens it. Storage is now a row per section — absent means
the default, and setting a rail back to the default DELETES its row, so the
file only carries what was actually changed. The select moves into each row of
the configurator, and the cut moves into `order_by_prefs`, keyed by the PREF id:
`mostStreamed` resolves to a per-tab variant candidate, so the section's own id
is not always the id the user configured, and that loop is the only place both
are in hand.

OPEN OVERLAYS LET THE WHEEL THROUGH. A MouseArea consumes presses and NOT wheel
events, so with the configurator open the pointer still scrolled the page
underneath — over the panel it moved the panel's own list and the page behind it
at once. These overlays are plain Items rather than Popups, so nothing grabs for
them and each has to stop the wheel at its own surface: the configurator, the
genre popup, the playlist-category popup and the Local Library funnel.

Release build clean, offscreen smoke green, 421/421 and 215/215.

cc
2026-08-19 07:34:28 -06:00
vicrodh 26ceeab828 feat(qt): mixed playlists, page fade, category filter, per-context recents
Six parity fronts against the Slint/Tauri references, plus the applied-filters
tooltip the app never had.

MIXED PLAYLISTS — the bug. A mixed playlist is a QOBUZ playlist carrying local
and Plex rows through library.db's playlist_local_tracks / playlist_plex_tracks
sidecar tables. This port could WRITE those rows (the picker) and COUNT them
(the Playlist Manager badge), but playlist_qt::load built its rows exclusively
from the Qobuz API response, so a track added to a playlist never appeared on
the page it was added to. Two comments elsewhere claimed the merge already
happened; they were describing the reference. Ported literally:
read_sidecar_rows_blocking (healing + bulk Plex resolve) and interleave_rows
(absolute-slot claim, same-slot collisions all emit), merged in load, with the
merged queue adopted as the open-detail snapshot.

Routing had to split with it. local_playlist_qt::open_id() was the one
predicate for every action, and a mixed detail adopts that same snapshot — so
it now means "a local:<uuid> detail OR a mixed one", which is right for
play/shuffle/row-play and wrong for the writes. Remove and reorder route on the
new local_detail_open() instead; without that, removing a local row from a
Qobuz playlist wrote into the local-playlist repo, found nothing, and reported
success. Removal by source is a third arm: the Qobuz arm parses the display id
as a u64 playlist_track_id, and a local row's display id is a library.db rowid
in the same numeric space. Reorder is refused on a mixed detail for the same
collision, in Rust and in the view.

PAGE FADE. 300 ms OpacityAnimator on the mounted page, OutCubic, honouring
reduceMotion. Zero added latency: the router's Loader is synchronous, the UI
thread is frozen through the mount and no frame exists in which a fade-OUT
could be shown, so the page snaps transparent in the same turn as the rebuild
and only the reveal is animated. An Animator and not a NumberAnimation because
it runs on the render thread — measured 0.53 vs 0.11 of progress with the GUI
thread blocked — and not on the shared pulse because the pulse arrives on the
very thread the mount blocks.

The first cut faded a veil coloured from the pane, and that was wrong in a way
that only shows in the configuration the app actually runs in: AppShell paints
the pane surface-main@0.22 while the ambient background is on, so a veil at
full opacity covered 22% of the page and the fade read as a flicker. Fading the
page has no such failure mode and keeps the ambient art visible.

SPLASH COPY. The login screen was already 1:1 (18 msgids, verified by diff).
The splash was missing the legal disclaimer the reference pins to the bottom of
the screen, drew "QBZ" / "QOBUZ PLAYER" as bare literals (English in all eight
locales), and hardcoded the dark palette — black on the other 34 themes.

CATEGORY FILTER on the Home playlists rail. Slugs now travel per card and the
offered set comes from the same /discover/index response, so the filter is
client-side over the cached 40 with no extra request. The selection lives in
Rust because HomeView is destroyed on every navigation; the reference keeps its
own for the same reason. New trigger + popup, and a `leading` slot on
QbzSectionHeader to hang it off the title line.

APPLIED-FILTERS TOOLTIP. A third QbzTooltip mode taking [{group, values}],
below the control with an upward caret. Overflow is staged summary and never
scroll — the overlay is enabled:false by construction, so a scrollable tooltip
could not be scrolled. Wired where a filter is INVISIBLE (collapsed to a count
or a glyph): the genre chip in four hosts, the Home tags, the Local Library and
Purchases funnels, the Artist Scene genres.

RECENTS BY CONTEXT. QueueTrack already carried context_kind and exactly one
function threw it away: record_queue_track wrote both album histories on every
track start whatever the user had put on, so one 40-track playlist filled a
24-entry album rail with that playlist's contents. album_play_events gains an
additive context_kind column (the other frontend names its columns on insert,
so it writes '' and '' counts as an album — no history reinterpreted), plus a
new playlist_play_history.db and a "Recently Played Playlists" rail, default on.
A separate file rather than a field in recently_played.json, which is a
destructive round trip between frontends: serde drops what it does not know and
the next write puts the truncated object back.

ITEMS PER CAROUSEL, recovered from Tauri and never ported to Slint. It lives in
a NEW TABLE of discover_prefs.db, not in the prefs blob: to_json rebuilds the
object from the keys the running binary knows and save overwrites the whole
column, so a new key would survive only until the other build's next toggle.
Default is uncapped, i.e. today's behaviour.

Two DiscoverySectionId arms were added to the frozen Slint tree, obliged by the
shared enum's exhaustive matches — same two-line follow MostPlayedAlbums took.
Not verifiable here: compiling that crate pulls qbz-ui.

Also fixes two recommendations_qt tests that had been red since c02e7713d,
which turned the two Weekly rows into ordering slots that deliberately do not
self-hide while the assertions kept the pre-split behaviour. Verified by stash
that they fail without any of this change: the code was right, the tests stale.

Eight locales updated. Release build clean, offscreen smoke gate green,
qbz-qt 421/421 and qbz-app 215/215.

cc
2026-08-19 07:00:35 -06:00
vicrodh 99722aedaf feat: an unreachable track can no longer stop playback
Two owner requirements, one shape: something that cannot play must be
skipped, and cleaned when there is nowhere left to skip to. The causes
are outside our control — Qobuz withdraws a track overnight, or a
local/Plex file sits on a share this network cannot see today.

THE FUNNEL, which is where the real gap was. `play_current_if_local`
returned a bool whose two `false`s meant opposite things: "not my row,
run the Qobuz path" and "my row, and it cannot play". The caller could
only act on the first, so an unreachable local file fell through to the
Qobuz tier-walk, failed there with an unrelated error, and
`auto_skip_unavailable` then declined it as non-terminal. One dead file
stopped playback silently. It is now a three-state `LocalPlay`, and a
local failure takes the SAME bounded walk a pulled Qobuz track takes —
to the user they are the same event.

CLEANING. Both dead ends of that walk (budget spent, queue edge) now
call `purge_unavailable_upcoming`, which drops only rows
`queue_track_unavailable` already refuses to play, walking backwards so
a removal cannot shift an index still to be visited. A track on an
unreachable share can never land there: it is Unreachable, not dead.

PROBES THAT CANNOT HANG (`qbz-library/src/reachability.rs`, 4 tests).
The machinery for network shares already existed — mount_info,
`library_folders.is_network`, a populated `local_tracks.is_network_mount`,
even an unreachable-folder guard in scan.rs. Every one of them probed
with a bare blocking syscall, and a mounted-but-unreachable share does
not make `exists()`/`read_dir()` fail, it makes them BLOCK for the
mount's timeout. The guards could not fire; scan.rs hung on the very
condition it existed to detect.

Three states, not two, and that distinction is the whole safety
property: `Missing` is the filesystem saying the file is gone and may
be cleaned; `Unreachable` is it saying nothing, and may only ever be
skipped. Collapsing them — which a bare `exists()` does — would delete
a user's NAS library because they changed networks.

The deadline goes on the PROBE only, never the transfer: a hi-res FLAC
over a working-but-slow share legitimately takes longer than any probe
budget, and timing the read out would turn "your network is slow" into
"this track is gone". A blocked syscall cannot be cancelled, so a
timed-out probe leaks its thread; the mount is therefore remembered
with a cool-down, one probe per dead mount rather than one per track,
which on a 2000-row queue is the difference between a defence and a
thread bomb.

PERSISTENCE. `busy_timeout=1000` on all 18 store pragmas. Deliberately
NOT the 2-3s discussed: these writes run on the Qt thread, so a 3s wait
would install exactly the stall this whole sweep has been removing,
while today's 0 means contention loses a setting silently and instantly.
One second resolves transient contention without a visible stall.

`home_bridge::ui` announces both of its silent failure modes. Three
builds went into proving it was NOT dropping the Home document during
the launch freeze; one warn line would have answered it in one.

Verified against the owner's real session state (1935-row queue): the
app launches to a painted Discover with no QML errors and no dropped
publishes.

cc
2026-08-18 12:40:52 -06:00
vicrodh 371be04be8 feat(models): Track.streamable becomes Option<bool>, and absence stops meaning dead
`#[serde(default)]` on a bare bool means an absent key deserializes to FALSE,
so a catalog Track from any endpoint that omits `streamable` came back marked
unavailable. The models' own tests document this as a hazard rather than a bug
(the "streamable split": PurchaseTrack defaults true, catalog Track defaults
false, and the comment warns that a wrong default "inverts that whole screen's
behaviour"). The inferred OpenAPI disagrees with the code and documents
`default: true`.

That was survivable only because nothing read the field. It stops being
survivable the moment a frontend greys rows out and keeps them out of the
queue: one terse endpoint would blank an entire view, silently.

`None` now means the endpoint made no claim, and only `Some(false)` marks a
track pulled. `is_streamable()` is the ONE place absence is interpreted, and it
resolves to available: refusing to play a track because a response was terse is
strictly worse than playing one that turns out to be dead, which the reactive
skip path already handles. Album.streamable was already Option<bool> with a doc
comment naming the same causes, so this makes Track consistent rather than
novel.

Every consumer in the workspace moves to the helper: both matchers, the ISRC
validator, the daemon driver, the purchases service (which maps catalog Track
into PurchaseTrack and therefore feeds the very screen the split protects), the
radio pool, the mixtape resolver and the QConnect renderer. The replaced tests
pin both directions of the new contract.

Also here, because it lives in the same file as the matcher's streamable skip:
`rank_candidates`, an additive public entry point over the existing weighted
matcher (ISRC short-circuit, title/artist/album weights, duration bonus,
stop-word normalisation, quality tie-break). `match_tracks` is untouched.

cc
2026-08-17 14:40:57 -06:00
vicrodh f75e1a4f47 feat(app): add frontend-agnostic device_cap module (#638 fix 3)
The local output-device quality cap lived in the Slint binary crate, which
is why the row never reached the Qt frontend: it is app-glue in a crate Qt
cannot depend on. Added to qbz-app, which already depends on qbz-models,
qbz-audio, tokio and log, so no new dependency edge. The Slint copy is left
untouched and keeps its own now-stale version.

Two behavior changes over the original, both deliberate:

Tier boundaries are tightened. A tier is a CEILING the service may deliver
at, so naming one the device cannot play defeats the cap: an 88.2 kHz DAC
must not resolve to Hi-Res (deliverable at 96) and a 176.4 kHz DAC must not
resolve to Hi-Res+ (deliverable at 192).

refresh() now returns a CapChange so the caller can decide about the audio
cache without a racy before/after read. The first refresh of a process is
Seeded, not Changed: the cap cache starts empty, so at boot None -> Some
looks like a user change, and the invalidation it triggers unlinks every
cached audio file on disk. A naive comparison wiped the whole playback
cache on every launch with the cap enabled.

A generation counter, re-checked while holding the CAP write lock, refuses
a superseded commit. Without it "enable (slow probe) then disable" could
let the stale probe resurrect a cap the settings say is off; checking the
counter before doing the work left exactly that window open.

refresh() also takes the active backend, so a "System default" selection
resolves through the backend that is playing instead of always PipeWire.

cc
2026-08-17 11:09:14 -06:00
vicrodh c2944db973 feat(qt): source-aware playback, session persistence and loading affordances
One batch, committed together because the changes interleave in main.rs and
splitting them after the fact would be a fiction.

Playback routing: a track now carries its source, so a local or Plex row played
from Discover stops being resolved against Qobuz first (the 404s and the long
silent wait before audio). Jellyfin and Navidrome land on the same seam.
Plex tracks stream through the player instead of downloading whole files first;
the feeder moved into qbz-player so both frontends can reach it.

Session persistence, both levels the port was missing:
  - The startup-page preference is finally read. It had been rendering and
    persisting a value nothing consumed. `last_view` is written in the SHARED
    ui_prefs vocabulary, not this port's route ids -- four of the six differ,
    and writing the raw ids would have silently broken the other frontend's
    restore. `on_session_entered` recorded "home" unconditionally, which also
    rewrote last_view on every boot, so the setting could not survive its own
    startup.
  - Queue and active track survive a restart. session_persist was tied to a
    frontend by exactly one type alias, so it moved to qbz-app generic over the
    adapter rather than being copied. Verified end to end: the snapshot restored
    here had been written by the Slint binary, and the exit flush keeps an
    un-consumed resume position instead of overwriting it with the engine's
    idle 0.

Loading affordances: the transport shows a spinner from the moment a play is
dispatched, and the restored session explicitly takes it back down -- a
restored track has no dispatch behind it and no audio ahead of it, so nothing
would ever have cleared it. Cortinilla results now navigate at album and artist
level.

Verified by the offscreen smoke gate and by driving a restore/quit cycle over
MPRIS.

cc
2026-08-13 23:10:32 -06:00
vicrodh b38b0b3d57 feat(qt): instant cached cortinilla paint (default on, ui_prefs opt-out)
The reference shows a skeleton while loading and never paints cached rows
first, and its comments present that as the design. It is not: the instant
paint existed during development and was removed because it GLITCHED -
results visibly jumped between the cached paint and the fresh one. The
owner's reading is that the glitch was a Slint rendering problem rather than
something inherent to stale-while-revalidate, so it comes back here as a
reversible experiment: DEFAULT ON, with a ui_prefs opt-out
(cortinilla_instant_paint) that needs no rebuild to flip. The skeleton path
stays fully implemented as both the opt-out target and the cold-miss path.

What is cached is the FINISHED payload, not the API response. Reviving the
existing CAPA A result cache would have left the expensive half on the table:
a cortinilla payload is the response AFTER ranking, truncation, i18n titles,
the top-result pick and - the part no API cache can shortcut - the local
library query and its derived grouping.

qbz-app gains a generic PayloadCache<T>. The store never inspects T, which is
what lets both frontends share it despite their payload types having diverged
(one raw artwork_url vs an art_url/art_path pair). In-memory only: process
lifetime IS the TTL, because a payload embeds local rows and resolved artwork
paths and a persisted entry would resurrect albums the user has deleted.

Four properties are what should keep it from glitching, and each is a real
constraint rather than a hope:

  - Same shape in both paints. The entry is written AFTER the local sections
    are appended, so there is no mid-apply reflow left to see.
  - Equality-gated replacement. CortinillaData derives PartialEq, so an
    unchanged repeat query produces ZERO second paint.
  - The selection survives - the publish path never writes selectedIndex.
  - The scroll survives - C5's contentY latch. Without it the second paint
    yanks the user to the top, which is the glitch by another name.

There are exactly TWO write points, and the placement is load-bearing: after
pass-1 artwork resolution, and again at the pass-2 republish once downloaded
covers land. Writing earlier caches rows with empty art_path, and since the
QML draws art_path only, a hit would paint cover-less rows AND the gate would
fire on every hit - the exact repaint this exists to avoid.

Invalidation: bound per session, dropped on logout, and cleared from
reload_browse - the single chokepoint every local-library mutation passes
through, since there is no library-changed signal to subscribe to.

Also fixes the immersive skeleton's scope bug, which is the twin of the one
C4b fixed on the desktop and which I missed there: a Timer derives from
QtObject, which has no parent property, so the unqualified parent resolved
out of the block entirely - the timer ran forever, pulse never changed, and
the skeleton never pulsed. Found by qmllint, not by reading. It picks up the
reduceMotion tick with the fix.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C11
(section 5.c, rulings R1/R6, R7-10).

Gate: build clean, 258/258 tests + 2 new in qbz-app, both QML audits OK,
qmllint clean on both cortinillas, offscreen smoke 0 complaints + home
published.

cc
2026-08-03 18:09:04 -06:00
vicrodh a69491a49e feat(player): re-wire the low-memory profile into qbz/qbzd (#660)
The system_capabilities profiler (Normal vs LowMemory, built for #331)
lost all its consumers in the Tauri removal; 2.0.2 shipped it as dead
code and qbzd got OOM-killed (exit 137) on 1 GB Pi hosts at track
transitions. Phase A wiring:

- Frontends read memory_profile() at startup and push it down via the
  new player memory_tuning (initial-buffer cap, L1 budget, low-memory
  gate) before the Player is created.
- L1 audio cache sized from the profile (400 MB Normal / 50 MB
  LowMemory; was hardcoded 400 MB).
- Oversized tracks (>L1/4 = 12.5 MB on LowMemory) skip the promotion
  clone and the parallel CMAF RAM accumulator entirely; the finished
  track is streamed to the L2 disk cache in 1 MB chunks straight from
  the playback buffer — no second in-RAM copy. Gapless degrades to a
  small gap for those tracks (a gap beats an OOM kill).
- Stream buffer preallocates total_size up front (try_reserve_exact,
  1 GiB cap, dynamic fallback) — no more ~8 doubling reallocs + memcpy
  per track (weak-CPU relief).
- New memory-pressure watchdog (15 s): critical pressure evicts the
  L1 (L2 disk kept) and halts prefetch; low pressure pauses HiRes
  prefetch (downgrades to Lossless). Spawned by both frontends; qbzd
  aborts+joins it for the #521 shutdown ordering.
- Prefetch honors the profile (depth cap, HiRes->Lossless downgrade).

Normal-profile behavior is byte-identical (every behavioral change is
gated on the LowMemory class; profile values equal the old hardcodes).

Phase B (follow-ups): tempfile spill of the live stream buffer for
giant tracks, POLLERR flood rate-limit, desktop kick_prefetch halt
check, L2-read on the play_track cache-hit path.

km
2026-07-26 23:56:39 -06:00
vicrodh 96acc4386a test(qbz-app): install the rustls CryptoProvider in shell tests
Follow-up to #663: with reqwest no longer pulling a rustls provider,
QbzCore::new (MusicBrainzClient) panicked "No provider set" in 6
shell tests. The test ctor now calls ensure_crypto_provider(), exactly
like the real binaries do at startup.

km
2026-07-26 23:56:38 -06:00
François Laignel a01097708e qbzd: install the rustls process-level CryptoProvider
The rustls crypto provider was not installed for `qbzd`, which caused the
process to panic:

> thread 'tokio-rt-worker' (15737) panicked at [...]/rustls-0.23.37/src/crypto/mod.rs:249:14:
>
> Could not automatically determine the process-level CryptoProvider from
> Rustls crate features.
> Call CryptoProvider::install_default() before this point to select a
> provider manually, or make sure exactly one of the 'aws-lc-rs' and 'ring'
> features is enabled.
> See the documentation of the CryptoProvider type for more information.

The function `ensure_crypto_provider()` was defined in the `qbz-cast` crate
which is obviously not a dependency of `qbzd`.

This commit moves `ensure_crypto_provider()` to `qbz-app`, calls it from `qbzd`
and updates `qbz` accordingly.
2026-07-26 21:12:44 +02:00
vicrodh 6c6eaf2b36 merge: Most Played Albums (feature/most-played-albums)
Play-count album history store (album_play_history, WAL per ADR-002) hooked
into record_recent, Most Played Albums carousel on Home + For You (default
off), View-all page with search + per-card play count, i18n in all 8
locales.

Conflicts resolved against pre-release: kept the Slint-era Pinned section
alongside the new MostPlayedAlbums everywhere (DiscoverySectionId, prefs,
render kinds, nav, ContentView, HomeActions); AlbumCardItem carries both
is_pinned and plays (populated via card_to_item); .po files union-merged,
msgfmt clean; discover_prefs tests updated (home 15/8 ON, forYou 14/13 ON)
— 10/10 green. Release build green.

km
2026-07-22 17:46:09 -06:00
vicrodh 1c48bd6224 fix(qbz-app): add mpris_enabled to the daemon_prefs roundtrip test literal
The `playback.mpris` field added to DaemonPrefs broke the qbz-app roundtrip test
(a `DaemonPrefs { .. }` literal missing the new field) — a compile error the
`test-crates` CI caught but `cargo test -p qbzd` didn't (it doesn't compile
qbz-app's test module). Add the field with a non-default value and assert it
survives the save/load roundtrip. Full non-UI workspace suite green (67 ok/0
failed) via scripts/cargo-test.sh.

cc
2026-07-20 18:53:33 -06:00
vicrodh 102bbba9c7 feat(qbzd): make MPRIS a persisted, TUI-toggleable setting (default on)
MPRIS enablement moves from env-only to a persisted daemon pref, exposed as the
canonical `playback.mpris` setting key so it round-trips through `qbzd settings
show|set`, the settings bundle, AND a toggle in the setup-TUI:

- daemon_prefs gains `mpris_enabled: bool` (default true; unknown-field guard
  updated).
- `playback.mpris` added to the settings KEY_TABLE (ApplyClass::None), with the
  read (`settings show`) and write (`settings set`) arms; the per-canonical-key
  round-trip test now covers it.
- setup-TUI Playback screen: a new "MEDIA CONTROLS" group with a System media
  controls toggle, carrying a description line ("MPRIS: KDE/GNOME media widget +
  media keys · restart to apply") since it applies at next daemon start.
- `mpris::enabled(roots)`: QBZD_MPRIS env still wins when set (deploy override);
  otherwise the persisted toggle decides. Default stays ON.

280 tests green.

cc
2026-07-19 22:51:20 -06:00
vicrodh 83df91439c feat(library): All mixed feed, TrackCard, artist playlists + library toggle, local favorites
- Library "All": mixed feed (tracks/albums/artists/playlists/labels) sorted by
  date added, reusing the existing cards; new TrackCard; toolbar (search + source
  switches + sort); "All" added to the Library nav surfaces.
- ArtistPage: restored the curated Playlists carousel (parsed but dropped in the
  Slint port); added the catalog/library toggle backed by a per-session
  per-artist index (favorites).
- Local-library favorites layer: new SQLite store (mirrors the pinned service),
  album-level; excludes the Qobuz offline cache at write time. Feeds "All" behind
  the show-local switch.
- i18n: 3 new strings across all 8 locales.

cc
2026-07-18 01:33:23 -06:00
vicrodh 7138dafbdb feat(home): Pinned section — per-user store, mixed carousel, pin affordances
Users pin albums/artists/playlists to a configurable "Pinned" section on Home
and For You. New per-user SQLite pinned-items store (headless in qbz-app +
lifecycle wrapper), a mixed PinnedCarousel dispatching Album/ArtistGrid/Playlist
cards by kind, a rotated pin glyph (hover-only) on those cards, and Pin/Unpin
from the playlist header and the album/artist context menus. Local/Plex albums
resolve to their Local Library page (id-format routing) and render their real
covers via the mixed-source artwork dispatcher.

cc
2026-07-16 16:30:30 -06:00
vicrodh 8021393925 fix(qbzd): mask secret values in import summary and distinguish reload refusal
cc
2026-07-14 19:58:31 -06:00
vicrodh 0b7cd6c5e3 feat(qbz-app): settings bundle engine with importer-side classification
Export/import CLI on qbzd; requirements 4 and 5 complete with zero desktop builds.

cc
2026-07-14 19:44:01 -06:00
vicrodh e700084f0b feat(qbzd): playback routes and transport CLI verbs with full advance ritual
cc
2026-07-14 16:35:21 -06:00
vicrodh 8534a02f0d test(qbz-app): pin duration==0 end-detection edge in playback driver
cc
2026-07-14 14:22:02 -06:00
vicrodh 297bbad326 feat(qbz-app): headless playback driver with pure plan_tick and thin IO shell
cc
2026-07-14 14:09:22 -06:00
vicrodh d911f43856 feat(qbzd): qbzd.toml with unknown-key warning, flock instance lock, daemon_prefs store
cc
2026-07-14 13:10:08 -06:00
vicrodh abb529e22b feat(discover): Most Played Albums — play-count store, hook, section id
Data foundation for the new Most Played Albums rail. New qbz-app store album_play_history (SQLite, mirrors play_history): one album_play_event row per track-start + an album_meta side table; top_albums(n)/all_albums() rank by COUNT(*) GROUP BY album_id, tie-broken by most-recent play. Hooked in playback::record_recent next to the recently/artist stores, same album identity. Registers the mostPlayedAlbums section id in discover_prefs (Home + For You, default off). The Qobuz API exposes no most-played endpoint (verified vs the inferred OpenAPI), so the ranking is local. 15 unit tests green (store 5 + prefs 10).

cc
2026-07-13 23:30:28 -06:00
Afonso Jorge Ramos c9b9aea118 fix(scrobble): skip tracks shorter than 30 seconds 2026-07-13 16:12:06 +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
vicrodh 13fce17bd1 refactor(qbz-app): drop Tauri-only boot/graphics/settings modules
Remove boot_watchdog, graphics_detection, and settings::{download,legal,
library,image_cache} (only src-tauri consumed them). Split
graphics_autoconfig: keep Environment/detect_environment/detect_gpu_name
(used by the Slint diagnostics panel), drop the WebKit Recommendation half.

cc
2026-07-10 01:02:38 -06:00
vicrodh 338d20df9b fix(home): render all 13 configurator sections; own recent-albums history
Two Home bugs, one dropped port. #566: four configurator sections
(favoriteAlbums 'Library Albums', qobuzMixes, releaseWatch, topArtists)
were offered and persisted but had no HomeView render arm and no data
in load_home — enabling them produced an invisible row. All four were
genuine Tauri-Home features (DiscoveryView.svelte arms :632/:653/:660/
:666, loaded unconditionally), so this is the full port, not a trim:
own HomeState fields (separate lifecycle from ForYouState), shared
foryou fetch pipelines (favorite_album_cards / fetch_release_watch /
top_artist_cards extracted pub(crate), For You behavior untouched),
Home-scoped artwork targets, and HomeView arms mirroring ForYouView.
load_home joins the three fetches concurrently with the index — zero
added latency. A HOME_RENDERABLE guard next to descriptors_for is now
the single source of truth: an enabled id without a render arm can
never emit an invisible descriptor again.

#567: 'Recently Played Albums' starved because albums derived from the
24-track history (long albums collapse to ~4 distinct). recently.rs
now keeps a separate album history (dedupe by id at record time, own
24 cap) with additive migration from the legacy bare-array format;
tracks keep their cap; load()/load_albums() signatures unchanged.

Zero new msgids (all three titles reuse For You's). qbz-app tests
10/10; slintcheck green.

cc
2026-07-08 11:46:57 -06:00
vicrodh b6e56e3b47 feat(session): guest local-only profile — offline mode without a prior login
The 'Start offline' link was gated on has-previous-session and the
shell backstop refused to open without a recorded user, so a user who
never logged in (e.g. a purchases-only Qobuz account, #553) could not
use the app at all. The link is now always visible and the offline
shell falls back to the GUEST profile — user 0, the Tauri-era fallback
this port used to refuse — building a real per-user store set (local
library, mixtapes, prefs) under users/0. AppRuntime::activate_offline
already resolved user 0 and never persists it as the last-user marker,
so the guest never masquerades as a real session.

First real login takes possession: AppRuntime::activate renames the
users/0 data+cache dirs to the account's id when that account has no
profile on this machine yet (existing profiles always win — the guest
dirs stay parked, no merge). Best-effort, login never breaks on a
failed rename.

cc
2026-07-06 21:47:00 -06:00
vicrodh 3fbb166b3e feat(blacklist): add String-keyed album blacklist store in qbz-app
Parallel album-id axis alongside the u64 artist blacklist. New
album_blacklist TEXT-PK table in the same artist_blacklist.db (additive,
idempotent, no migration), an in-memory HashSet<String> mirror, and
is_album_blacklisted/add_album/remove_album/get_all_albums/album_count/
clear_all_albums. Shares the single enabled flag with the artist axis.
12 unit tests green (incl. shared-flag + axis-independence).

cc
2026-06-29 23:03:47 -06:00
vicrodh 99da4c381b feat(slint): pre-launch dev panel, queue controls, appearance persistence, reco cache settings
Sleep timer (queue footer): tokio monotonic deadline + 1Hz countdown that
fires core().pause() at expiry; presets + custom minutes via a popover.

Stop after this song: 4 QbzCore passthroughs + playback consume-before-advance
seam and the load-bearing gapless-prefetch gate, plus the queue marker/menu so
the Queue persists when playback halts.

qbz-log crate: TeeLogger wrapping env_logger (stderr + 5000-line ring +
~/.local/share/qbz/logs/qbz.log) with write-time secret redaction (regex +
live-secret registry) and a diagnostics bundle builder.

Dev panel logs: in-app viewer that shows content the moment it opens, advanced
controls behind a discreet [+], "Copy all" as the sole footer button, a copy
button on the upload URL, and a redacted public paste upload.

Diagnostics: qbz-app::diagnostics (extracted, src-tauri untouched) feeding a
7-section panel with the live active output device + sample rate/format (pactl)
and a complete redacted report for the upload.

Report an Issue: wires the header menu item to a 1:1 Tauri modal that opens the
GitHub bug-report template.

Appearance persistence: 6 toggles that were live-only now persist via ui_prefs
+ handler arm + startup seed (window-title-show, show-volume-steppers,
sidebar-playlist-collage, local-library-track-artwork, in-app-toasts,
theme-filter).

Recommendations cache (per-tab only): TTL select 24/36/48/72h + Refresh now in
ExternalRecoView; no change to the other Discover tabs.

Bump workspace version 0.1.0 -> 1.2.15 so app_version is correct in the
diagnostics report and the QConnect About string. i18n es/de/fr/pt for all new
strings.

cc
2026-06-29 13:19:06 -06:00
vicrodh 332fa430e5 feat(reco): Recommendations in all Discover flyouts + opt-out setting
Surface the new Recommendations tab everywhere Discover sub-sections are listed,
and make it opt-out.

- Flyouts: the sidebar section-nav flyout and both header nav flyouts (full text
  + compact icon) now list "Recommendations" alongside Home / Editor's Picks /
  For You. The Rust route handler already maps "discover-recommendations"
  (strip_prefix + select_tab + ensure_loaded), so no routing change was needed.
- Opt-out: a show_recommendations flag (default on) persisted in the per-user
  discover-prefs store (qbz_app), seeded into SettingsState on session start.
  Settings > Integrations gains a "Show Recommendations in Discover" toggle whose
  description nudges connecting Last.fm + ListenBrainz (MusicBrainz is used
  automatically to match releases). Off => the tab pill and every flyout entry
  disappear.
- Gating uses two mounted instances per flyout (one with the entry, one without)
  because Slint can't type-infer [NavMenuEntry] from a ternary of array literals;
  the HomeView pill uses a plain if.

cargo check -p qbz green.

cc
2026-06-27 11:57:46 -06:00
vicrodh 25ab1408e5 feat(reco): augment discovery known-artists with reco signal
Add RecoStore::get_known_artist_ids (artists played >threshold OR favorited;
unit-tested) and union it into the network-discovery 'skip artists I already
know' filter. play_history stays the source of the (id, name) known set:
reco_events has no artist names and the schema is frozen to match Tauri, so
full consolidation (plan D2) would regress the name-based MB candidate filter.
This augments the id set with reco's richer plays+favorites signal instead.

cc
2026-06-26 20:03:19 -06:00
vicrodh bd887d755c feat(reco): add forgotten-favorites + genre-backfill engine queries
Two additive RecoStore queries with unit tests (all 8 reco_store tests green):
- get_forgotten_favorite_album_ids(limit, recency_days): favorite albums not
  played within the window. Ported from Tauri db.rs:391-426 but adapted to the
  integer created_at schema -- Tauri's datetime('now',..) string compare never
  matched the integer column, so the recency filter is a numeric cutoff.
- update_genre_for_album(album_id, genre_id): backfills genre_id onto an
  album's still-NULL events. Plays log no genre, so the frontend writes it back
  on album resolution; this is what makes get_top_genres non-empty (Tauri
  defined the backfill fns but never called them, so its top-genres was empty).

cc
2026-06-26 19:51:31 -06:00
vicrodh 22fb4162f7 feat(slint): search cortinilla — main-header + immersive
Intelligent Search (phases 3-4): a frontend-agnostic SearchService in qbz-app (Capa A result cache + Capa B interaction ranking) behind a live header dropdown with skeleton loading, stale-while-revalidate, 220ms debounce, keyboard nav + scroll-into-view, per-section caps, a URL-match artwork guard, and an opt-out Settings toggle. Typing previews in the dropdown; Enter goes to Search > All; local-library results show in the dropdown only.

Immersive search: an in-immersive glass cortinilla (Albums/Artists/Playlists) that switches playback without navigating — a Settings action select (disabled/replace-queue/play-next/add-to-queue), the HUD stays up while searching and never hides while the cursor rests on the chrome, twinned top buttons, and a clear button with always-centered field text.

cc
2026-06-20 17:38:02 -06:00
vicrodh ef93c86437 feat(app): headless artist-blacklist service extracted from src-tauri
cc
2026-06-16 22:19:36 -06:00
vicrodh 314ed8e6ba feat(reco): shared headless reco-store module (qbz-app)
Cleanroom port of the Tauri reco-store into qbz-app::settings::reco_store
(frontend-agnostic, no tauri::State, ADR-006) so the Slint frontend can seed
DailyQ/WeeklyQ, the WeeklyQ 7-day window, and top-genres. Opens the SAME
per-user reco/events.db that Tauri uses (cross-frontend history share); ports
the reco_events schema + indexes, log_play/favorite_event, get_recent and
get_favorite track ids, a windowed get_recent_track_ids_since, get_top_genres,
get_home_seeds, and the train() scorer verbatim. 6 headless tests green.
Tauri's own reco_store is left as-is (rewiring it is a deferred cleanup).

cc
2026-06-15 16:00:41 -06:00
vicrodh 2a37f2ad91 feat(discover): add per-tab section-prefs core store (configurator model)
Frontend-agnostic port of Tauri discovery-v2/sectionPrefs.ts (ADR-006):
the ordered per-tab {id,enabled} model for the Discover configurator
(Home / Editor's Picks / For You). Pure model logic (default_prefs,
migrate with the legacy-array / object / garbage branches, reconcile_list
with drop-unknown + first-wins dedup + append-missing, toggle, move_section
with boundary clamp, reset_tab, is_enabled, enabled_count, enabled_ordered)
plus a per-user SQLite store (dedicated discover_prefs.db, single JSON-blob
column in the Tauri-compatible shape). 10 headless tests green.

Lives in qbz-app::settings::discover_prefs so any frontend (Slint, Tauri)
drives the configurator from one source of truth.

cc
2026-06-15 12:45:14 -06:00
vicrodh 3adb517236 feat(slint): ImmersiveView base - reusable atmosphere + compact player + audio-transport lift
Foundation for the ImmersiveView Slint port. Authored by Codex and accepted by
the owner after live visual iteration; committed here so the panel work builds
on top of it. The Album Reactive foreground was intentionally left out (a prior
attempt was rejected) so it can be rebuilt cleanly on this base.

Audio-transport lift (slice 1):
- qbz-audio: VizFrame/VizSink trait + spawn_visualizer_thread; FFT producer
  lifted off tauri::AppHandle. Protected audio path untouched (seam =
  RingBuffer.snapshot).
- src-tauri: fft_processor gutted to a thin TauriVizSink emitting byte-identical
  viz:* events.
- qbz-app: AppRuntime::with_visualizer + visualizer_tap accessor.
- qbz-slint: SlintVizSink + install() publishing VisualizerState
  bars/spectral/energy/waveform/transient at 30fps, zero IPC.

Immersive base (slice 2a):
- immersive.rs: host-side atmosphere generation from decoded artwork
  (8x8 sample -> upscale -> blur -> saturate/contrast/normalize -> vignette),
  mirroring Tauri texture-loader.ts::generateAtmosphere; plus glow_color.
- ImmersiveAtmosphere.slint: reusable animated atmosphere (4 counter-drifting
  copies via animation-tick); usable static for Album/Artist headers later.
- ImmersiveView.slint: base overlay (atmosphere + auto-hide Tauri-shaped compact
  player only). Foreground panels are rebuilt on this base.
- AppShell mounts the overlay; ViewMode menu opens Immersive in PlayerBar/Small.

cc
2026-06-13 04:11:53 -06:00
vicrodh 952846b6a0 feat(slint): Last.fm and ListenBrainz scrobblers wired to player and settings
Re-implements the parked scrobblers-wip stash intent on the current tree
(the stash no longer applied; it remains untouched in the stash list) with
three corrections: the Last.fm offline queue uses the SHARED per-user
offline_settings.db scrobble_queue (byte-identical SQL to Tauri - queues
flush from either frontend) instead of a private table; the ListenBrainz
cache opens Tauri's per-user path so LB creds and listen_queue are truly
shared (a Tauri LB sign-in pre-seeds Slint); and the queue flush is driven
by the offline engine's online edge (plus initial flush), not login-only.

New frontend-agnostic qbz_app::settings::scrobblers store (master enable,
Last.fm session/username, LB token/username; tests) and the shared
scrobble-queue API on OfflineModeStore. Player wiring hooks the de-duped
track-change edge inside the QConnect remote gate: now-playing fires on
change (skipped offline), the scrobble arms at min(50%, 240s) with a
generation counter cancelling stale timers, all sources scrobble (Tauri
parity incl. the #360 version-enriched title). Offline or call failure
queues; Last.fm flush honors the 50-batch and 14-day cutoff.

Settings gains an Integrations section (Plex-section idiom): Last.fm
browser-auth connect/finish/disconnect, ListenBrainz token field +
validate, per-service enable toggles. Known gap documented: Last.fm creds
cannot be shared with Tauri (webview localStorage); LB creds and both
queues are shared.

cc
2026-06-10 02:52:17 -06:00
vicrodh c207f232c5 fix(offline): logout reopens the Qobuz gate; auth endpoints exempt from it
Logging out of an offline session left offline_session set and the gate
closed, refusing the very OAuth exchange that could end the offline state
(user-reproduced: sign-in failed with OfflineMode after logout). teardown()
now ends the session-scoped offline state (offline_session and the cached
induced flag reset; the persisted preference reloads on the next
activation) and recomputes, reopening the gate when connectivity allows.
Two regression tests added.

The three login methods (login, login_with_oauth_code, login_with_token)
are exempt from the offline gate: user-initiated sign-in is explicit
intent to reach Qobuz; the gate governs services. Verified none of them
reach gated sub-calls; bundle cold-start stays gated on purpose. New unit
test pins the exemption. The recovery sign-in falls back to the full
browser OAuth flow when the saved token is gone or rejected, and
offline_session is lifted only on success paths.

cc
2026-06-10 01:32:14 -06:00