Commit Graph
100 Commits
Author SHA1 Message Date
vicrodh dae22574a6 feat(window-settings): add available_titlebar_modes and is_sandboxed helpers
Detection of KDE+Wayland for Plasma-mode availability. The is_sandboxed
helper drives the frontend caveat hint about Flatpak/Snap.

cc
2026-05-03 14:50:04 -06:00
vicrodh 045236f716 feat(window-settings): add TitlebarMode enum and titlebar_mode column
Adds a 4-value mode enum (Qbz | System | Plasma | Hidden) to the existing
WindowSettings store, with column migration that infers mode from the
legacy use_system_titlebar flag. Setter is write-through so the legacy
column stays consistent for any remaining readers.

cc
2026-05-03 14:44:41 -06:00
vicrodh 773fa390eb i18n(settings): make qconnect startup-mode title explicit
"Auto-connect on startup" → "Auto-connect Qobuz Connect on startup"
(and equivalents in es/de/fr/pt). The earlier wording was ambiguous —
it could read as auto-connecting QBZ itself, not the QConnect feature.

cc
2026-05-01 16:01:51 -06:00
vicrodh 63745a480f fix(qconnect): trigger startup auto-connect after OAuth restore, not during setup
The auto-connect spawn was firing at the end of the Tauri setup hook,
before the frontend invoked runtime_bootstrap. At that point the API
client is not yet initialized (bundle tokens not extracted, no auth),
so service.connect always failed silently and the lifecycle stayed Off
even when startup_mode was On or Remember-last-on.

Move the trigger into v2_runtime_bootstrap, right after the OAuth token
restore + session activation + CoreBridge auth complete. The CLI
override (--enable-qconnect / --disable-qconnect) is exposed via a
managed QconnectCliOverride wrapper so bootstrap can read it.

cc
2026-05-01 15:58:14 -06:00
vicrodh 74775aaebf Merge branch 'feature/internal/qconnect-startup-persistence' into pre-release 2026-05-01 14:25:59 -06:00
vicrodh aa0f3c97eb i18n(settings): add qconnect startup mode strings (en/es/de/fr/pt)
Six new keys under settings.integrations.qconnect.startupMode covering
the dropdown labels, description, and local-library compatibility note.

cc
2026-05-01 13:34:11 -06:00
vicrodh 56951c6510 feat(settings-ui): qconnect startup mode dropdown + local-library hint
Adds the three-state dropdown to Settings > Integrations next to the
device-name field, plus a permanent compatibility note about local
library being incompatible with QConnect.

cc
2026-05-01 13:30:23 -06:00
vicrodh a6fb1cd018 feat(qconnect): write-through last_known_state on connect/disconnect
When startup_mode == RememberLast, persist the latest on/off state so
the next launch can restore it. No-op for Off/On modes.

cc
2026-05-01 13:10:39 -06:00
vicrodh 7bbe68d5b0 feat(qconnect): register v2_qconnect_get/set_startup_mode in invoke handler
cc
2026-05-01 13:08:57 -06:00
vicrodh 398b4ea807 feat(qconnect): add v2_qconnect_get/set_startup_mode commands
String-typed wire shape for predictable serde behavior across the
Tauri invoke boundary. Validates against QconnectStartupMode::from_str.

cc
2026-05-01 12:39:43 -06:00
vicrodh d5ae31aa1b fix(qconnect): move startup auto-connect to end of setup hook
The auto-connect spawn was running on a worker thread before CoreBridge
inner init completed, risking undefined behavior in service.connect.
Moving it to the end of setup ensures all init runs first.

cc
2026-05-01 12:37:48 -06:00
vicrodh 6f38362b9f feat(qconnect): apply startup_mode + CLI override during app setup
Spawns a background auto-connect task during the Tauri setup hook when
the effective startup decision says so. Failures log a warn and leave
lifecycle in Off; no infinite reconnect loop on first-time failure.

cc
2026-05-01 12:30:32 -06:00
vicrodh 27cb2ab6cc feat(qconnect): parse --enable-qconnect / --disable-qconnect CLI flags
Volatile per-launch override. Both flags together → enable wins
deterministically with a warn log. The override is threaded into
lib::run() and consumed in the next task.

cc
2026-05-01 12:20:35 -06:00
vicrodh 40851ad58e feat(qconnect): add startup_mode + last_known_state persistence helpers
Reuses qconnect_settings.db with WAL pragma. Fail-open on any error so
a corrupt DB falls back to Off and never blocks app startup.

cc
2026-05-01 12:16:28 -06:00
vicrodh 1b01c95898 feat(qconnect-app): add QconnectStartupMode + compute_effective_startup
Pure enum + decision function for QConnect auto-connect-on-startup.
Persistence and CLI parsing stay in the Tauri adapter; this crate
remains UI-agnostic.

cc
2026-05-01 12:12:09 -06:00
vicrodh c6fb25275e poc(about): add Kawarp attribution
Adds @kawarp/core (MIT, by better-lyrics) to the About modal's
"Built with" section, in the same Lucide-anchored slot. New i18n
key about.attributions.kawarpDesc landed in all five locales.

cc
2026-04-29 09:03:36 -06:00
vicrodh 3ab83e6224 poc(immersive): retire standalone KawarpPanel — Static now uses kawarp via ImmersiveBackground
After the central refactor in c106928a, ImmersiveBackground in 'full'
mode renders kawarp directly. Static (and every other panel that
consumes ImmersiveBackground — Coverflow, Lyrics, Queue,
AlbumReactive, TrackInfo, Suggestions, the split modes) now inherits
the kawarp look automatically.

The standalone KawarpPanel was a temporary A/B vehicle for the POC
benchmarks; with the new architecture it's redundant and would only
confuse the panel selector.

Removed:
  - src/lib/components/immersive/panels/KawarpPanel.svelte
  - 'kawarp' from FocusTab and ImmersiveViewKey type unions
  - Menu entry in ImmersiveHeader
  - Routing branch in ImmersivePlayer
  - i18n key in en/es/de/fr/pt
  - 'kawarp' from the bg-skip exclusion list (no longer needed —
    Static no longer has its own canvas, it uses ImmersiveBackground)

cc
2026-04-29 02:58:47 -06:00
vicrodh c106928a53 poc(immersive-bg): replace WebGL2 path with kawarp in 'full' mode
ImmersiveBackground feeds every panel that doesn't render its own
canvas (Static, Coverflow, Lyrics-focus, Queue-focus, AlbumReactive,
TrackInfo split, Suggestions split, the legacy split lyrics/queue),
so refactoring it once swaps the bg engine across all of them in a
single change.

What changed:

  - 'full' mode used to dynamic-import ImmersiveAmbientCanvas (a
    bespoke WebGL2 renderer maintained in-house) and a CSS-blur
    canvas as fallback. Replaced with a kawarp WebGL canvas and
    'lite' mode as the WebGL-failure fallback (was already the
    watchdog's degradation target). Drops ~400 lines of legacy
    canvas blur code from the bundle indirectly — the dynamic
    import path is gone.
  - Kawarp is mounted directly inside the component on a bound
    canvas via $effect once the canvas is in the DOM.
    transitionDuration: 1000 ms means kawarp handles cross-fades
    natively, so when in full mode we skip the manual CSS opacity
    dance the lite/off modes still need.
  - Tuning matches the values landed on KawarpPanel after the A/B
    rounds (warp 1.0, blur 7, speed 1.15, scale 1.0, saturation 1.5,
    tint 0.06). Comments cite the rationale.
  - switchToMode disposes the kawarp instance when degrading away
    from full, so no leaked GL context after a watchdog event.
  - lite + off modes are unchanged, including their CSS-fade logic.

cc
2026-04-29 02:57:11 -06:00
vicrodh a48d1b369d poc(kawarp): saturation 1.7→1.5, tint 0.03→0.06
cc
2026-04-29 02:49:57 -06:00
vicrodh ded6537ee0 poc(kawarp): saturation 1.9→1.7
cc
2026-04-29 02:46:56 -06:00
vicrodh 0b844e53a0 poc(kawarp): bump animationSpeed 1.0→1.15
cc
2026-04-29 02:46:05 -06:00
vicrodh 30aec49b72 poc(kawarp): nudge saturation 2.0→1.9 and blurPasses 8→7
Tiny dial-back per visual feedback — saturation was clipping faintly
on bright covers, and one fewer blur pass leaves a hair more cover
texture in the warp without re-introducing blob artefacts.

cc
2026-04-29 02:45:44 -06:00
vicrodh f92ef50832 poc(kawarp): push saturation/tint further in the same direction per feedback
User feedback after the previous tweak (saturation 1.8, tint 0.08):
the result felt visually close to the demo defaults — i.e. not enough
chromatic punch from the cover yet. Push further in the same direction:

  - saturation 1.8 → 2.0 so muted palette colours register at full
    strength on low-contrast covers without amping detail extraction.
  - tintIntensity 0.08 → 0.03 effectively removes the global dark-blue
    tint pull. The trace amount keeps a faint anchor for pure-black
    regions so they don't float as flat colour, but the cover's own
    palette dominates the result.

cc
2026-04-29 02:44:32 -06:00
vicrodh 7d54bf840b poc(kawarp): boost saturation, drop tint instead of widening scale
Last tweak (scale 1.5 + speed 1.2) made kawarp surface non-dominant
colours by exposing more cover detail per frame, but the side-effect
was that kawarp started looking closer to Static — same "translate of
regions" feel — defeating the differentiation that's supposed to be
kawarp's selling point.

Different lever: keep the abstract domain-warp look (scale 1.0,
speed 1.0 — exactly the demo) but push the colour pipeline:

  - saturation 1.5 → 1.8 amplifies muted palette so secondary colours
    of low-contrast covers register without forcing detail through.
  - tintIntensity 0.15 → 0.08 halves the global dark-blue tint pull
    so the cover's own palette dominates the result rather than the
    tint dragging everything toward navy.

Result is more chromatic variance with the same fluid abstraction —
keeps kawarp clearly distinct from Static while addressing the
"non-dominant colours don't show" feedback.

cc
2026-04-29 02:42:28 -06:00
vicrodh 69023c199c poc(kawarp): wider sample area + slightly faster drift to surface non-dominant colours
User feedback after round 2 of the A/B benchmark in the immersive view:
metrics-side kawarp is fair (CPU/GPU equal to Static within noise,
+20 MB RSS for the WebGL textures), but the existing Static panel
exposed non-dominant colours from the cover better — its
ImmersiveBackground translates the blurred image so different
regions dominate the bg over time.

Two parameter bumps to bring kawarp closer to that behaviour without
abandoning its fluid-warp look:

  - scale 1.0 → 1.5: widens the area the warp samples per frame so
    the secondary colours of the cover (peach band, yellow accents,
    etc.) come through instead of being averaged into the dominant
    tone.
  - animationSpeed 1.0 → 1.2: slightly faster drift so the colour
    movement reads as movement, not slow ambience.

cc
2026-04-29 02:39:10 -06:00
vicrodh ec1f54a6a9 poc(kawarp): match upstream demo defaults + lift content over the canvas
Two fixes after first round of A/B in the immersive view:

  - The kawarp params shipped were a tamer profile (warp 0.65, slower
    speed, deeper blur, more dither) chosen defensively. On low-contrast
    monochromatic covers (e.g. Billie Eilish "HIT ME HARD AND SOFT"
    underwater shot) the result was visibly grainy and almost static
    versus the lively domain warp on kawarp.boidu.dev. Restore the
    upstream demo defaults exactly so the look in QBZ matches what
    users see on the homepage before evaluating performance fairly.

  - The canvas at z-index 0 was painted ABOVE .track-info, hiding the
    NOW PLAYING / title / artist / album lines entirely. .artwork-wrapper
    and .artwork-container have their own position:relative so the
    cover survived, but the metadata wrapper didn't. Anchor both content
    wrappers at position:relative; z-index:1 so every panel surface
    sits above the canvas regardless of its inner positioning.

cc
2026-04-29 02:27:06 -06:00
vicrodh 3ad68a175a poc(immersive): wire kawarp WebGL background into KawarpPanel
Adds @kawarp/core (vanilla TS, zero deps, MIT) as a dev dep and mounts
it on a full-bleed canvas inside KawarpPanel, behind the existing
artwork/title content. The panel signals ImmersivePlayer to skip the
global ImmersiveBackground when active so the comparison vs Static
isn't a double-blur.

Settings tuned for album art ambience:
  - warpIntensity 0.65 (subtle organic motion, not hallucinatory)
  - blurPasses 12 (avoids visible bands on high-contrast covers)
  - animationSpeed 0.6 (slow drift, doesn't fight the music)
  - transitionDuration 1000 ms (matches the gapless lead-time so the
    background is already on the new artwork before the audio swap)
  - tintIntensity 0.12 (gentle dark anchor for low-key covers)

Lifecycle:
  - onMount creates the renderer, loads the current artwork, starts
  - $effect reloads on artwork change (kawarp does its own crossfade)
  - ResizeObserver keeps the WebGL backing store in sync with layout
  - onDestroy disposes the GL context

Compositing layer hint (will-change/translateZ) mirrors the fix used
on the 1.2.10 sticky headers — keeps WebKitGTK from repainting
surrounding chrome on every animation frame.

cc
2026-04-29 02:03:35 -06:00
vicrodh 0373b9c1fc poc(immersive): add KawarpPanel as exact duplicate of StaticPanel
Baseline checkpoint for the kawarp benchmark. KawarpPanel is currently
identical to StaticPanel (same template, same CSS, same animation set
— just an equalizer on the title row when playing). Wired into the
immersive panel selector so it can be picked side-by-side with Static
for A/B measurement.

Subsequent commits will introduce the kawarp WebGL background only
inside this panel; if the GPU/CPU/RAM delta is unjustified the panel
gets removed without touching StaticPanel.

cc
2026-04-29 01:59:29 -06:00
vicrodh fd7b589866 chore(flake): bump to v1.2.10
Update qbzVersion, srcHash (via nix-prefetch-url of the v1.2.10
tarball) and npmHash (via prefetch-npm-deps on package-lock.json).

cc
2026-04-29 00:43:30 -06:00
vicrodh e0fd0584fa chore(release): bump version to 1.2.10 + tag notes
Hotfix on top of 1.2.9 to address the gapless-play regression
introduced by dee987c1a's tightened isGaplessTransition gate combined
with a long-standing race in the player's transition-swap atomics.

Bumped:
- src-tauri/Cargo.toml + Cargo.lock
- src-tauri/tauri.conf.json
- package.json + package-lock.json
- snapcraft.yaml (version + source-tag) — packaging/snap/snapcraft.yaml
  follows automatically via symlink
- packaging/flatpak/com.blitzfc.qbz.metainfo.xml (new release entry)

cc
2026-04-29 00:19:32 -06:00
vicrodh 4fb7f92c67 Merge bugfix/internal/gapless-transition-swap-race into pre-release 2026-04-28 23:47:49 -06:00
vicrodh 43ac3e18fa fix(player): order gapless slot reset before track_id swap to close race
The audio-thread iteration that swaps in the next gapless track
mutates several atomics in qbz-player::player::SharedState. The
polling loop in lib.rs reads those atomics independently (one load
per field) at ~250 ms cadence and emits a PlaybackEvent that the
frontend handlePlaybackEvent then classifies. When the polling load
falls inside the swap window, it can observe an inconsistent mix:
current_track_id already advanced to the new track, but
gapless_next_track_id still pointing at that same track because the
reset hasn't run yet.

The frontend's isGaplessTransition predicate
(playerStore.ts) requires both `event.track_id !== currentTrack.id`
AND `event.gapless_next_track_id === 0`. The mid-swap event
satisfies the first and fails the second, so the event is mis-
classified as an external track change. The "Track changed
externally" branch then asks the queue manager for the current
queue track (still the previous track because the gapless flow
hasn't called v2_next_track yet) and prints the now-familiar
"Queue track mismatch or null: 212171 vs event: 216597" warn.

In 1.2.8 this race was self-recovering: subsequent ticks observed
the consistent post-swap state and isGaplessTransition fired one
tick later. dee987c1a tightened the gate with
`gaplessAttemptedForCurrent`, which gets reset to null by the
mid-swap event's external-track path; subsequent ticks then fail
the new gate and the misclassification becomes permanent for the
remainder of the track. UI stays on the previous title while the
audio plays the new one, and the queue cursor never advances —
visible to the user as "the player says it's still on Viento but
Afuera is playing".

Fix the race at the producer side: in both transition swap paths
(mainline pos>=dur detection and ALSA Direct take_source_transition),
clear gapless_next_track_id and gapless_ready BEFORE storing the
new current_track_id. With SeqCst ordering, any reader that
observes the new track_id is guaranteed to also observe the
cleared gapless slot — eliminating the inconsistent intermediate
state by construction. Cheaper than coordinating reads on the
consumer side and keeps the polling loop's lock-free design.

cc
2026-04-28 23:47:39 -06:00
vicrodh 9f02efb8f8 Merge bugfix/internal/gapless-request-armed-sticky into pre-release 2026-04-28 23:26:39 -06:00
vicrodh 9e7cd2973b fix(player): clear gapless flags on transition swap so next prefetch can fire
Two regressions ride together when gapless plays a chain of more than
two tracks:

  - The pos/dur snapshot at the top of the audio-thread iteration is
    captured BEFORE the gapless transition swap. After the swap
    updates current_track_id and duration, the local pos/dur still
    point at the outgoing track, and the "approaching end" check a
    few lines below fires immediately for the new track with stale
    299/299-style values.
  - That spurious firing arms gapless_request_armed=true and sets
    gapless_ready=true. gapless_request_armed only gets cleared again
    on a future transition (which will never happen because the new
    track has no prefetch queued) or on engine empty (i.e. when
    playback has already stopped). gapless_ready also stays sticky-
    true because nothing else clears it once the false-positive fires.
  - End result: the new track plays through with the gate stuck
    closed, the real "approaching end" check at ~T-10s never fires,
    the frontend never gets prompted to call play_next_gapless for
    track #N+1, and the audio thread reaches engine empty and stops.

Fix:
  - Track whether a transition swap consumed pending in this
    iteration via a local transition_consumed_pending bool. Skip the
    "approaching end" check while that flag is set — the next
    iteration will run with fresh pos/dur for the new track and
    fire the trigger correctly.
  - Reset gapless_ready=false in both transition swap paths
    (mainline and ALSA Direct), mirroring the existing
    gapless_request_armed=false reset, so neither flag survives a
    transition.

cc
2026-04-28 23:26:33 -06:00
vicrodh b1904ad70d chore(flake): bump to v1.2.9
Update qbzVersion, srcHash (via nix-prefetch-url of the v1.2.9
tarball) and npmHash (via prefetch-npm-deps on package-lock.json).

cc
2026-04-28 22:31:23 -06:00
vicrodh ec3fbf1d3f chore(ci): remove build-qbzd workflow
The qbzd daemon is still in early development and the build pipeline
isn't ready to ship artifacts. Drop the workflow until the daemon
work lands so it doesn't fire on tag push and produce broken jobs.

cc
2026-04-28 21:40:13 -06:00
vicrodh ca8084f1af chore(about): codename Quiet Polish for 1.2.9
Match the in-app About modal to the v1.2.9 tag notes.

cc
2026-04-28 21:18:23 -06:00
vicrodh 93a07acd9f docs(release-1.2.9): personalise tag notes
Reorganise the release notes around the user's voice: lead with the
"polish + bugfix + perf" framing, group user-visible polish first,
QConnect cleanup and the Look-and-Feel block (sidebar collage cache,
tray live tooltip, middle-click + scroll, icon variant picker, ksni
worker thread) next, and close with a thanks-and-roadmap paragraph
about the daemon work pushing the next release out a bit.

Credits @afonsojramos for the macOS contributions.

cc
2026-04-28 21:12:43 -06:00
vicrodh f17a1733f7 chore(release): bump version to 1.2.9 + tag notes
Quiet Polish: QConnect cleanup (#316) and a long round of Linux
tray polish. Sticky-header gap fix (no more padding-top on scroll
containers), Edit folder shortcut from the playlist sidebar (#364),
shift-range + Ctrl/Cmd+A multi-select across the remaining views,
opt-in resume of seek position (#317), and Qobuz subtitle/edition
propagated through queue / MPRIS / scrobbling (#360).

Bumped:
- src-tauri/Cargo.toml + Cargo.lock
- src-tauri/tauri.conf.json
- package.json + package-lock.json
- snapcraft.yaml (version + source-tag)
- packaging/flatpak/com.blitzfc.qbz.metainfo.xml (new release entry)

packaging/snap/snapcraft.yaml is a symlink to the root snapcraft.yaml
so it tracks automatically.

cc
2026-04-28 20:29:59 -06:00
vicrodh fc402506bf chore(icons): regenerate icon.icns from current logo
The bundled icon.icns was a 1024x1024 PNG masquerading as an .icns
container (placeholder, since no proper libicns tooling is installed
on the build machine). Regenerated from the current icon.svg so the
artwork at least matches the rest of the branding. A real .icns
container with multiple Apple-spec sizes still needs to be produced
by a macOS contributor.

cc
2026-04-28 20:29:52 -06:00
vicrodh 17730e0233 fix(tray): icon variant picker with Color option and mono labels
Three issues with the 1.2.9-pre tray icon override:

  - The mono PNGs filled the entire canvas, so the tray icon read as
    larger than its panel neighbours (Plasma + GNOME).
  - The dropdown labels were inverted: picking "Light icon" produced
    the dark glyph and vice-versa, because the constants were named
    after the panel they suit ("light panel" -> dark glyph), not after
    the glyph colour the user expects to see.
  - There was no way to pick the colour vinyl logo even on desktops
    where a monochromatic glyph clashes with the rest of the tray.

Rebuild the mono pixmaps (22/32/44/64) from icon-symbolic.svg with a
2px content inset; render a new colour set from icon.svg at the same
sizes; rename the Rust constants to glyph-colour names and introduce
an IconVariant enum so the UI label, the persisted value, and the
file selection all line up.

Dropdown options now read Auto / Mono light / Mono dark / Color in
all five locales. normalize_tray_icon_theme on the Rust side and
normaliseTrayTheme in SettingsView migrate the inverted "light" /
"dark" values stored by 1.2.9-pre to the user-intent equivalents.

cc
2026-04-28 20:29:49 -06:00
vicrodh 8cdcdd45a6 Merge feature/internal/tray-icon-theme-toggle into pre-release 2026-04-28 19:12:29 -06:00
vicrodh 388a150893 feat(settings): tray icon theme dropdown for Linux
Adds an Auto / Light icon / Dark icon dropdown next to the existing
tray toggles, hidden on macOS. Wires v2_set_tray_icon_theme so the
choice persists and the live SNI tray reflects it instantly.

i18n keys settings.appearance.tray.iconTheme.* added to en/es/de/
fr/pt.

cc
2026-04-28 19:02:50 -06:00
vicrodh ee713e3f0c feat(tray): persisted icon theme override with live update on Linux
Auto-detected dark vs light tray glyph fails on GNOME, whose top bar
is permanently dark even when the system color scheme is light, so
the dark glyph disappears against it. Add a persisted user override
("auto" / "light" / "dark") that the SNI tray respects at startup
and on the fly.

Backend pieces:
- TraySettings gains tray_icon_theme: String with "auto" default and
  a normalize_tray_icon_theme() helper that clamps unknown values
  back to "auto". SQLite migration ALTERs the table to add the
  column when an older DB is opened.
- tray_linux_ksni::decode_tray_icons takes an optional override and
  resolve_dark_tray() consults it before falling back to the
  existing GNOME/GTK/KDE auto-detection. init() forwards it.
- LinuxTrayHandle gains set_icon_theme(theme); a new
  TrayUpdate::SetIconTheme variant flows through the dedicated
  worker thread so re-decoding plus handle.update() happen outside
  the tokio runtime (same panic-avoidance pattern used for
  tooltip updates).
- v2_set_tray_icon_theme persists the value, mirrors it to the
  global startup store, and pushes it to the live tray handle so
  the icon flips without restart.

cc
2026-04-28 19:02:46 -06:00
vicrodh bf81814a65 Merge bugfix/internal/sticky-header-phantom-block into pre-release 2026-04-28 18:50:51 -06:00
vicrodh a9fccc6fe8 fix(views): pin sticky headers flush by removing scroll-container padding-top
Commit e05ed48d covered a real WebKitGTK gap by painting bg-primary in
two places: as a 32px upward box-shadow above .jump-nav /
.tracks-sticky-header, and as a fill on the .artist-detail /
.playlist-detail scroll containers. The upward box-shadow rendered as
a phantom dark block above the bar even when not pinned, sitting on
top of the social-action buttons in ArtistDetailView.

The actual cause is that CSS sticky pins to the scrollport's
padding-box top, not the border-box top. With padding-top: 8px on the
scroll container the pinned bar landed 8px below the visible top
edge, leaving the gap the original commit was trying to hide.

Move the 8px out of the scroll container's padding-top and onto the
first child (.back-btn margin-top in ArtistDetailView, .nav-row
margin-top in PlaylistDetailView). The sticky bar now pins flush. Drop
the upward box-shadow and the bg-primary fills on the scroll
containers — neither is needed once the gap is gone. Add 10px
padding-top inside .tracks-sticky-header so its taller toolbar doesn't
press against the titlebar when pinned.

cc
2026-04-28 18:50:02 -06:00
vicrodh e1922d20dc Merge feature/internal/playlist-folder-edit-shortcuts into pre-release 2026-04-28 16:38:34 -06:00
vicrodh 1a7ecccaf3 feat(playlist-manager): edit pencil in folder breadcrumb
Inside a folder the breadcrumb showed the folder name with no way to
reach the edit dialog from there. Adds a small Pencil button beside
the current folder name that reuses the existing
openEditFolderModal() helper, mirroring the per-card edit affordance
on the folders grid.

Closes part 2 of #364.

cc
2026-04-28 16:34:45 -06:00
vicrodh dd512ee5ac feat(sidebar): edit folder from context menu
Adds an "Edit folder" entry at the top of the playlist-folder context
menu in the sidebar, opening the existing FolderEditModal. The modal is
mounted globally in +page.svelte (separate instance from the one in
PlaylistManagerView) so it works from any view, and uses Tauri's ask()
for delete confirmation per the destructive-dialog convention.

Closes part 1 of #364.

cc
2026-04-28 16:34:42 -06:00
vicrodh d3ccf40ddd Merge feature/internal/tray-rich-tooltip into pre-release 2026-04-28 16:22:31 -06:00
vicrodh 7714d83212 fix(tray): apply tooltip updates from a dedicated thread
ksni 0.3.4 with the "blocking" feature wraps every Handle::update in
Runtime::block_on, which panics when called from inside an existing
tokio runtime — exactly where Tauri commands and our async polling
loop run. The panic was being swallowed by the runtime, so set_track
and set_playing looked like no-ops and the SNI tooltip kept rendering
the static "QBZ / Music Player / Nothing playing" fallback.

Move all updates onto a dedicated std::thread that owns the ksni
blocking handle and processes mutations from a std::sync::mpsc
channel, fully outside any tokio context.

cc
2026-04-28 15:59:25 -06:00
vicrodh 789b0262b6 feat(tray): live tooltip and scroll/middle-click controls on Linux
Replicate the Plasma media plasmoid tray behavior on Linux SNI panels:

- Tooltip now shows live track title, "by Artist", album line, and
  "Middle-click to play/pause" + "Scroll to adjust volume" hints.
- Middle-click (SecondaryActivate) emits tray:play_pause instead of
  toggling the window — the window toggle stays on left-click.
- Mouse wheel (Scroll) emits tray:volume_delta with normalised ticks
  (~120 SNI units per notch -> +/-5% volume per tick), wired to the
  existing handleVolumeChange path on the frontend so QConnect remote
  renderers and the local player both respond.

Architecture: ksni 0.3.4's blocking Handle is now stored as
LinuxTrayHandle in Tauri state. v2_set_media_metadata pushes track
fields into the tooltip, and the playback poll loop mirrors the
play/pause flag so the hint flips while the panel popup is open.

cc
2026-04-28 15:29:04 -06:00
vicrodh a47d00cd71 Merge feature/queue-stop-after into pre-release
Adds two queue actions per #356:
- Stop after this track (single transient marker, pauses on natural
  end, queue intact). Works with both standard and gapless playback
  paths.
- Remove all tracks after this (drops queue tail, toast feedback).

Backend lives in qbz-player::queue (V2 cleanroom) so headless and
TUI clients also get the feature without UI-specific code paths.
44 unit tests in qbz-player::queue, 4 Tauri commands, 5-locale
i18n with ICU plural for the toast.

Refs #356.

cc
2026-04-28 15:10:33 -06:00
vicrodh fbbbf72220 fix(playback): suppress gapless prefetch when current track has stop-after marker
Manual verification (#356) caught that the audio engine's gapless
prefetch was bypassing the marker. Sequence: ~10s before track end
the engine asks the frontend for the next track via the gapless
callback; once a successor is queued, the engine transitions
internally without firing setOnTrackEnded — so consumeStopAfterIf
never ran and the marked track's end seamlessly continued playing.

Fix: in the setGaplessGetNextTrackId callback in +page.svelte,
return null when the currently-playing track matches the marker.
With no successor pre-queued, the track ends naturally, fires
setOnTrackEnded, and the existing consume path pauses correctly.

Known edge: if the marker is set during the last ~10s of a track,
the gapless prep already happened and the transition still fires.
Acceptable for v1 — full coverage would require backend cancel of
in-flight gapless prep.

Refs #356.

cc
2026-04-28 15:10:17 -06:00
vicrodh 0d62007a1c feat(playback): wire stop-after marker into natural track-end handler
setOnTrackEnded in +page.svelte calls consumeStopAfterIf(finishedId)
before advancing. If it returns true: stop playback, set isPlaying
to false, return without advancing. Manual skip paths (player bar
Next, click on queue track) don't go through this callback, so the
marker correctly only fires on natural reproduction. Cast/DLNA
natural-end is covered by the same callback per playerStore.ts:631-633.

Refs #356.

cc
2026-04-28 14:37:32 -06:00
vicrodh 32c7cbd2ff fix(queue): use ICU plural for tracksRemoved toast
Per code review on commit cdf30a7b: the tracksRemoved key was
hardcoded plural ("1 tracks removed" reads wrong). Convert to ICU
plural form across all 5 locales using svelte-i18n's MessageFormat
support. Also align the new handlers with the existing closeMenu()
pattern for consistency.

Refs #356.

cc
2026-04-28 14:31:35 -06:00
vicrodh cdf30a7b74 feat(queue-panel): add stop-after and remove-after menu entries
Two new context-menu entries on queue tracks:
- Stop after this track (dynamic label: shows "Cancel" if already set)
- Remove all tracks after this (hidden on the last track)

Visual indicator: marked track replaces its index display with a
small CircleStop icon in the accent color.

Refs #356.

cc
2026-04-28 14:26:58 -06:00
vicrodh 813e4dd76e fix(i18n): restore player.queue string and put new keys in top-level queue namespace
Commit e40587e1 converted player.queue from a string to an object to
accommodate the new stop-after / remove-after keys. That broke five
call sites that read $t('player.queue') expecting the localized
"Queue" string (QueuePanel, ImmersiveHeader, ExpandedPlayer,
NowPlayingBar, immersive QueuePanel).

Restore player.queue to its original string form in all 5 locales,
and add a new top-level queue namespace for the four action keys
(stopAfterThis, cancelStopAfter, removeAllAfter, tracksRemoved).
This matches the plan's intended key paths: queue.stopAfterThis etc.

Refs #356.

cc
2026-04-28 14:23:01 -06:00
vicrodh e40587e1cb i18n(queue): add stop-after and remove-after keys to all 5 locales
en.json + es.json + de.json + fr.json + pt.json.

Refs #356.

cc
2026-04-28 14:16:18 -06:00
vicrodh 2f5a2b1b89 fix(queue-store): clear stopAfterTrackId in reset()
Per code review on commit ec6c99dd: the reset() function (called on
logout / user switch) was clearing all other queue state but missed
the new stopAfterTrackId writable, leaking marker state into a fresh
user session. Add the explicit clear.

Refs #356.

cc
2026-04-28 13:58:09 -06:00
vicrodh ec6c99dd81 feat(queue-store): add stop-after store and remove-after action
Derived store stopAfterTrackId reads from queueState (single source
of truth). Actions: setStopAfter / clearStopAfter / toggleStopAfter
mutate via v2_queue_* commands. consumeStopAfterIf is for the
natural-end handler. removeAfter wraps v2_queue_remove_after and
returns count for toast display.

Refs #356.

cc
2026-04-28 13:54:32 -06:00
vicrodh 27295dea98 fix(commands_v2): write-lock and debug-log v2_queue_consume_stop_after_if
Per code review on commit 97b5bd79:
- consume_stop_after_if mutates inner state on match (clears the
  marker), so use write().await for semantic consistency with the
  other mutating commands. The inner Mutex made read() work today
  but the intent is mutation.
- Demote the per-call log to debug — this fires on every natural
  track-end and would spam info-level logs during normal listening.

Refs #356.

cc
2026-04-28 13:52:23 -06:00
vicrodh 97b5bd79c7 feat(commands_v2): add four queue commands for stop-after and remove-after
v2_queue_set_stop_after / v2_queue_clear_stop_after / v2_queue_consume_stop_after_if
/ v2_queue_remove_after — thin wrappers over QueueManager via CoreBridge. The
consume command is what the frontend's natural-end handler calls before
advancing; manual skip paths do not call it.

Refs #356.

cc
2026-04-28 13:47:56 -06:00
vicrodh 3fe06195c6 feat(queue): expose stop-after marker through QueueState
The marker becomes part of the queue state snapshot the frontend
already consumes via syncQueueState(). Single source of truth — no
parallel event channel, no separate store on the frontend.

Refs #356.

cc
2026-04-28 13:23:38 -06:00
vicrodh 098176e0e3 feat(queue): add remove_after to drop tail of queue from a pivot
Removes all tracks at indices > pivot, preserving the pivot itself.
Returns the count removed. Updates shuffle_order and history to
drop dangling indices. Auto-invalidates the stop-after marker if it
referenced any removed track.

Refs #356.

cc
2026-04-28 13:20:26 -06:00
vicrodh 17dd52aa35 feat(queue): invalidate stop-after marker on queue mutations
set_queue, set_queue_with_order, and clear unconditionally drop the
marker (queue replaced/emptied → marked track may not exist anymore).
remove_track and remove_upcoming_track conditionally clear the marker
when the removed track matches it. move_track and add_* are unchanged
— a moved track is still the same track ID, and adding doesn't touch
existing tracks.

Refs #356.

cc
2026-04-28 13:16:40 -06:00
vicrodh 23ebe9bf1f feat(queue): add consume_stop_after_if for one-shot marker firing
Auto-advance drivers (frontend natural-end handlers) call this with
the just-finished track ID. If it matches the marker, returns true
and clears the marker; the caller pauses instead of advancing.
Otherwise returns false — caller proceeds with normal advance.
Manual skip paths must NOT call this method.

Refs #356.

cc
2026-04-28 13:13:28 -06:00
vicrodh d36adcbb4a feat(queue): add stop-after marker field and basic API
Add `stop_after_track_id: Option<u64>` to InternalState plus three
methods on QueueManager: `set_stop_after(id)`, `clear_stop_after()`,
`get_stop_after()`. Setting on an ID not present in the queue is a
silent no-op so the marker can never reference a phantom track.

Refs #356.

cc
2026-04-28 13:10:40 -06:00
vicrodh af41d66f18 fix(scrobble): enrich track title with version for Last.fm and ListenBrainz
The metadata fix from #360 wired formatTrackTitle() into the UI and
MPRIS so remix tracks display as "Title (Version)", but the Last.fm
and ListenBrainz scrobble paths in playbackService.ts were still
sending track.title raw. Result: a remix played in qbz scrobbled as
the original song to both services.

Apply formatTrackTitle() at the caller of updateLastfmNowPlaying,
updateListenBrainzNowPlaying, and showTrackNotification. Both the
immediate now_playing and the deferred scrobble (plus the offline
queue) capture the title from the same closure variable, so a single
change at the caller covers all paths going forward. Refs #360.

cc
2026-04-28 09:21:51 -06:00
vicrodh 7eb8fc1533 fix(snap): bump source-tag to v1.2.8 to match version field
The snapcraft.yaml `version:` field was bumped to 1.2.8 but
`source-tag:` under parts.qbz still pointed to v1.2.0. The Snap
Store build farm clones the repo and runs `git checkout v1.2.0`,
so every revision since 1.2.1 was published with metadata "1.2.X"
but a binary compiled from v1.2.0 source — silent regression
visible only when the user opens the app and sees the wrong
version reported.

Affects 8 releases (1.2.1 through 1.2.8). Fixes #361.

cc
2026-04-28 09:21:43 -06:00
vicrodh f533660a43 fix(tray): rebuild Linux tray icon as monochrome multi-size pixmaps (#362)
The previous tray-light.png / tray-dark.png were 128x128 RGBA glyphs with a
black radial background. GNOME's StatusNotifierItem path expects monochrome
silhouettes around 22px base size and was rendering ours as the "three dots"
fallback.

Regenerate from icons/icon-symbolic.svg using Inkscape with a corrected
square viewBox (149 1 944 944, centred on the path bbox) and emit four
sizes per theme (22/32/44/64). decode_tray_icons() now returns Vec<Icon>
so ksni can offer the panel the best match for its bar height and HiDPI.

cc
2026-04-27 22:52:01 -06:00
vicrodh 5bfb748876 feat(session): opt-in resume playback position on session restore (#317)
Adds a separate "Resume playback position" toggle in Settings >
Playback. Default is OFF — opening qbz the next morning lands on a
clean 0:00 of the saved track, which is the common preference. Users
who do want bookmark-style resume turn the toggle on.

Independent of "Restore session on startup" in the UI (top-level
toggle, not a sub-setting). The seek only applies when both are on:
without persistSession there's no saved track to resume to, so the
flag is inert. Keeping the toggles separate matches the Settings UX
elsewhere and lets users flip persistence on without inheriting the
old #317 behavior we never had.

Backend
-------
- New `PlaybackPreferences::resume_playback_position: bool` (default
  false). Migration adds the column to the existing
  `playback_preferences.db` and the default row gets 0; existing
  installs upgrade transparently.
- Setter `set_resume_playback_position(bool)` on both
  `PlaybackPreferencesStore` and the wrapping `State`.
- Tauri command `v2_set_resume_playback_position` registered in
  lib.rs invoke handler. Get-side already reads the new field via
  the existing `v2_get_playback_preferences`.

Frontend
--------
- `playbackPreferencesStore.ts` — new field on the type and cache,
  new `setResumePlaybackPosition()` exported.
- `SettingsView.svelte` — separate toggle row right below
  "Restore session on startup" (matches the existing Settings rhythm).
- `playerStore.ts` — `pendingSessionRestore` extended with optional
  `positionSecs`. When present, after the restored track loads, the
  position is queued through the existing `pendingSeekPosition` /
  `flushPendingSeek` path so the seek runs against an already-loaded
  stream and respects the in-flight guard. Skip the queue when the
  saved position is at or near 0 (the load already starts there).
- `routes/+page.svelte` — at session restore, reads
  `getCachedPreferences().resume_playback_position` and forwards
  `session.current_position_secs` only when the user opted in. The
  field has been persisted in the schema since v1.2.5 — what changes
  is whether we read it back.

i18n
----
- New keys in 5 locales: `settings.playback.resumePlaybackPosition` /
  `…Desc`.
- `persistSessionDesc` updated everywhere — it used to claim the
  feature restored playback position, which was always misleading
  (the code restored the track but paused at 0:00). The text now
  matches what the toggle does on its own: queue + view, paused at
  0:00. Resume-to-position becomes the explicit ask of the new toggle.

Tests
-----
- Backend: 196 tests pass. Frontend: 0 TS errors.

cc
2026-04-27 21:29:29 -06:00
vicrodh 66f10ebc7a chore(svelte): remove dead CSS selectors flagged by vite-plugin-svelte
Eleven selectors had been left behind after various refactors when the
markup migrated to other classes. Vite-plugin-svelte logs them as
"Unused CSS selector" on every dev reload, drowning useful warnings
in noise.

Removed:

  src/lib/components/Sidebar.svelte
    .section-header-btn, .section-header-btn:hover,
    .section-header-btn .section-header

  src/lib/components/views/HomeView.svelte
    .section, .section-header, .section-header h2,
    .track-list, .track-list.compact,
    .track-list.compact :global(.track-row.compact)

  src/lib/components/views/ForYouTab.svelte
    .track-list, .track-list.compact

Each removal was verified against the template — none of the removed
selectors had a corresponding `class="..."` reference in the file.

npm run check: 41 → 30 warnings (the remaining ones are unrelated:
line-clamp / app-region / ARIA hints).

cc
2026-04-27 20:27:21 -06:00
vicrodh 65cb9ad7c4 feat(track): propagate version through queue builders and MPRIS (#360)
The previous two commits added the version field but several builder
helpers in routes/+page.svelte, miniplayer/+page.svelte, and view-
specific local Track interfaces still dropped it on the way to the
backend, so the on-screen rendering went back to the bare title for
queues built from anywhere except album/playlist detail.

Builders updated to pass version
--------------------------------
~50 inline `title: trk.title` / `title: track.title` assignments where
the destination is a BackendQueueTrack (sent to v2_replace_queue,
v2_add_to_queue_*, mixtape enqueue paths, infinite-radio start, etc.)
across:

  - src/routes/+page.svelte (album play, mixtape, search-result play,
    favorites/playlist play, history mapping, queue-from-session
    restore, infinite-radio resolution, dynamic suggest, etc.)
  - src/routes/miniplayer/+page.svelte (queue snapshot)
  - AlbumDetailView, ArtistDetailView, FavoritesView, FavQView,
    TopQView, LabelView, PurchasesView, PurchaseAlbumDetailView,
    SearchView, DynamicSuggestView, WeeklySuggestView
  - playbackService.ts (cast metadata, MPRIS metadata - via
    formatTrackTitle so MPRIS shows the full title with edition)
  - lyricsStore.ts (track context for lyrics queries)
  - immersive/panels/CoverflowPanel.svelte (rendered title in
    coverflow card uses formatTrackTitle directly)

Local Track interfaces extended with `version?: string | null`
--------------------------------------------------------------
- ArtistDetailView: Track and DisplayTrack interfaces
- LabelView: Track interface
- SearchView: Track interface
- FavoritesView: FavoriteTrack interface
- FavQView: FavTrack interface
- DynamicSuggestTrack (in src/lib/types/dynamicSuggest.ts)
- PurchasedTrack (in src/lib/types/purchases.ts)

Reverted (the auto-insert script touched these but the destination
type is not a track):
- AddToMixtapeItem: it's a wrapper for both album and track ops; the
  underlying track keeps version through other paths
- v2_set_media_metadata invoke and v2_cache_track_for_offline invoke:
  the backend command shapes don't have a version arg; can be added
  later if MPRIS/cache need to surface it independently
- Album-level constructions in some search paths: those build an
  Album (no edition concept at album-level for the search row)

Frontend npm run check: 0 errors. Backend cargo test: 196 passed.

cc
2026-04-27 20:20:52 -06:00
vicrodh 0129518d8f feat(track): propagate version through remaining UI surfaces (#360)
Second batch of formatTrackTitle adoption — covers all the components
that were rendering bare {track.title} after the core renderers in the
previous commit:

Modals
  - TrackInfoModal: heading
  - TrackReplacementModal: track-title row
  - AlbumCreditsModal: track-title cell

Views
  - HomeView, ForYouTab: TrackRow title prop + version propagation in
    toDisplayTrack()/buildContinueQueueTracks()
  - ArtistDetailView, LabelView, PurchasesView, PurchaseAlbumDetailView:
    track-title row in their list renderings
  - WeeklySuggestView, DynamicSuggestView, FavQView, TopQView,
    LocalLibraryView: TrackRow title prop
  - FavoritesView: getFavoriteTrackTitle accessor passed to
    VirtualizedTrackList

Immersive
  - immersive/panels/QueuePanel: now-playing + upcoming track titles
  - immersive/panels/TrackInfoPanel: heading

Search
  - PlaylistSuggestions: track-title row

Each file gains an `import { formatTrackTitle } from '$lib/utils/trackTitle'`
and the renderer that used to emit {track.title} now calls
{formatTrackTitle(track)} so remix/reissue editions show their subtitle
parenthesized, matching the mobile app.

Frontend npm run check: 0 errors. Backend cargo test: 196 passed.

The remaining `title: track.title,` assignments in builder helpers
(routes/+page.svelte, miniplayer/+page.svelte, favorites builders,
etc.) where version is not yet appended will be cleaned up separately;
they construct BackendQueueTrack from sources that may or may not
carry version, and adding the field requires per-call inspection of
the source type. The on-screen rendering bug from #360 is fixed for
all the views and modals listed above.

cc
2026-04-27 19:07:59 -06:00
vicrodh 6ff7aab61e feat(track): show Qobuz subtitle/edition (version) in core renderers (#360)
Qobuz returns a separate `version` field for tracks belonging to remix
albums, reissues, and deluxe editions (e.g. "Player's Ball Mix",
"Nine Inch Noize Version", "Remastered 2024"). The mobile app shows it
parenthesized after the title so remix albums are distinguishable from
originals; qbz only showed the bare title, making remix albums
impossible to navigate (issue #360).

Backend
-------
- Add `version: Option<String>` to qbz_models::Track (the V2 model
  consumed by qbz-qobuz/qbz-library) and to api::models::Track (the
  legacy struct still used by some V2 command paths).
- Add `version: Option<String>` to qbz_models::QueueTrack so the field
  travels through the local queue, persistence, QConnect remote queue,
  and frontend snapshots without being lost in conversion.
- Propagate `version` in every Track→QueueTrack converter:
  qconnect::model_track_to_core_queue_track,
  link_resolver::track_to_queue_track_from_api,
  mixtape::enqueue::track_to_queue_track_from_api and the inline
  builders. Plex/local builders set version=None (no edition concept
  outside Qobuz).
- V2QueueTrack (the wire shape exposed to the frontend) gains the
  field with serde default so older clients keep deserializing.

Frontend
--------
- New `formatTrackTitle(track)` helper in src/lib/utils/trackTitle.ts:
  returns `title` when version is empty, else `title (version)`.
- Add `version?: string | null` to QobuzTrack, Track (UI), PlaylistTrack,
  DisplayTrack, BackendQueueTrack, QueueTrack (UI), and PlayingTrack
  so the field flows from the wire to the renderer.
- Propagate `version` through the four buildQueueTrackFrom* builders
  in trackActions.ts and through the QobuzAlbum→AlbumDetail adapter.
- Use formatTrackTitle in the high-traffic renderers:
  - NowPlayingBar (currentTrack derived in routes/+page.svelte)
  - QueuePanel: now-playing label, upcoming-track rows, history rows
  - VirtualizedTrackList: default getTrackTitle accessor
  - AlbumDetailView: TrackRow title binding
  - PlaylistDetailView: TrackRow title binding
  - SearchView: popular-track card label and the two track-list
    rows (Tracks tab + cross-search "All" view)

Tests
-----
- 196 lib tests pass (no behavior change for the no-version path).
- npm run check: 0 errors.

Follow-up: more views still pass `{track.title}` directly (HomeView,
ArtistDetailView, FavoritesView, ForYouTab, MixtapeCollectionDetailView,
LocalLibraryView, the various Suggest/Top/Label views, the immersive
panels, the miniplayer surface, and the Track*Modal stack). They all
render UI Track-shaped objects that now carry version, so each is a
mechanical "import formatTrackTitle + replace {track.title} with
{formatTrackTitle(track)}". Doing them in a follow-up commit to keep
this one reviewable.

cc
2026-04-27 18:45:17 -06:00
vicrodh 532ec89095 refactor(qconnect): extract QconnectServiceState to service.rs
The final extraction of Alcance A. Move the public service facade
(QconnectServiceState struct + impl + Default impl, plus the spawned
event-loop task, the renderer/queue snapshot queries, the controller-
handoff helpers, send_command/send_renderer_report, the deferred
renderer-join plumbing, and the bootstrap_remote_presence pending-
action machinery) from mod.rs into a dedicated service submodule.

Items moved:
- struct QconnectRuntime, QconnectServiceInner
- pub struct QconnectServiceState (re-exported via `pub use`)
- impl QconnectServiceState (~50 methods) and impl Default
- bootstrap_remote_presence, deferred_renderer_join,
  clear_pending_if_matches
- queue_payload_track_preview, update_lifecycle_state_if_running,
  emit_qconnect_diagnostic (private helpers used only by the service)
- Constants: JOIN_SESSION_REASON_CONTROLLER_REQUEST,
  QCONNECT_PLAY_TRACK_HANDOFF_WAIT_MS,
  QCONNECT_PLAY_TRACK_HANDOFF_POLL_MS

All methods on QconnectServiceState are pub(super) so the commands
submodule (which was already extracted) can dispatch through them.
The Default impl uses plain `fn default()` (visibility qualifiers are
not permitted in trait impls).

mod.rs now contains only the cross-submodule glue (~140 lines):
shared constants (PLAYING_STATE_*, AUDIO_QUALITY_*, BUFFER_STATE_OK,
DEFAULT_QCONNECT_CHANNEL_COUNT, QCONNECT_REMOTE_QUEUE_SOURCE), the
QconnectRemoteSyncState accumulator, qconnect_now_ms,
qconnect_repeat_mode_from_loop_mode, normalize_volume_to_fraction,
model_track_to_core_queue_track, and the module declarations.

session.rs and commands.rs imports re-pathed to point at submodules
directly (super::transport::*, super::queue_resolution::*,
super::session::*, super::service::*) instead of going through
re-exports in mod.rs.

mod.rs: 1820 → 142 lines (-92%). service.rs: 1689 lines.

Final layout:

  src-tauri/src/qconnect/
  ├── mod.rs              ( 142)  module glue
  ├── types.rs            ( 273)  wire types, payloads
  ├── session.rs          ( 374)  topology, renderer snapshots
  ├── queue_resolution.rs ( 481)  cursors, shuffle order
  ├── corebridge.rs       ( 566)  apply_renderer_command, materialize
  ├── track_loading.rs    ( 337)  streaming, FLAC probe, dedup
  ├── event_sink.rs       ( 461)  QconnectEventSink + dispatch
  ├── transport.rs        ( 386)  credentials, device identity
  ├── service.rs          (1689)  QconnectServiceState facade
  ├── commands.rs         ( 849)  Tauri invoke handlers
  └── tests.rs            (1044)  37 unit tests

From 6371 lines in 1 monolith → 6602 lines across 11 focused files,
each with a single clear responsibility. mod.rs went 6371 → 142 lines
(-98%). All 37 tests pass. Alcance A complete; the codebase is ready
for unified-binary Phase 0 to resume.

cc
2026-04-27 18:00:43 -06:00
vicrodh 49f56d563e refactor(qconnect): extract test module into qconnect/tests.rs
Move the 37 unit tests (1044 lines, all #[cfg(test)] inside the old
mod tests {} block in mod.rs) into a dedicated tests.rs submodule.
mod.rs keeps only the declaration `#[cfg(test)] mod tests;`.

Test imports are unchanged — they still use `super::*` paths into the
focused submodules established in steps 2-9, which is exactly the
intended layout.

This concludes Alcance A of the qconnect refactor. Full layout:

  src-tauri/src/qconnect/
  ├── mod.rs              (1820 lines) — QconnectServiceState impl,
  │                                       glue, internal structs
  ├── types.rs            ( 273 lines) — wire types, request payloads
  ├── session.rs          ( 374 lines) — topology, renderer snapshots
  ├── queue_resolution.rs ( 481 lines) — cursors, indices, shuffle order
  ├── corebridge.rs       ( 566 lines) — apply_renderer_command,
  │                                       materialize, projection
  ├── track_loading.rs    ( 337 lines) — streaming, FLAC probe, dedup
  ├── event_sink.rs       ( 461 lines) — QconnectEventSink + dispatch
  ├── transport.rs        ( 386 lines) — credentials, device identity
  ├── commands.rs         ( 849 lines) — Tauri invoke handlers + report
  │                                       helpers
  └── tests.rs            (1044 lines) — 37 unit tests

From 6371 lines in 1 monolith → 7591 lines across 10 focused files
with clear responsibility boundaries. mod.rs went 6371 → 1820 (-71%).
Each submodule is independently navigable; visibility is constrained
via pub(super) so re-exports stay intentional. All 37 tests pass.

cc
2026-04-27 17:30:08 -06:00
vicrodh 7bd5562203 refactor(qconnect): extract Tauri commands to commands.rs
Move all 30 #[tauri::command] handlers (registered in lib.rs's
invoke_handler!) plus the renderer-report helpers they own into a
dedicated commands submodule:

Tauri commands (re-exported via `pub use commands::*` so lib.rs and
external callers keep their import paths unchanged):
- v2_qconnect_connect, _disconnect, _status, _send_command
- v2_qconnect_evaluate_queue_admission,
  _send_command_with_admission, _join_session, _set_player_state
- v2_qconnect_toggle_play_if_remote, _play_track_if_remote,
  _skip_next_if_remote, _skip_previous_if_remote,
  _set_volume_if_remote, _mute_if_remote, _stop_if_remote,
  _toggle_shuffle_if_remote, _cycle_repeat_if_remote,
  _set_autoplay_mode_if_remote, _autoplay_load_tracks_if_remote
- v2_qconnect_set_active_renderer, _set_volume, _set_loop_mode,
  _mute_volume, _set_max_audio_quality, _ask_for_renderer_state
- v2_qconnect_queue_snapshot, _renderer_snapshot, _session_snapshot
- v2_qconnect_report_playback_state, _report_volume
- v2_qconnect_get_device_name, _set_device_name
- v2_get_hostname

Renderer-report helpers moved alongside the report command:
- classify_qconnect_audio_quality,
  build_qconnect_file_audio_quality_snapshot,
  resolve_active_playback_audio_quality
- should_skip_renderer_report_due_to_stale_snapshot,
  determine_queue_lookup_report_strategy,
  should_report_queue_item_ids_for_renderer_state

mod.rs deps that commands.rs reaches via super:: are now pub(super):
- BUFFER_STATE_OK, DEFAULT_QCONNECT_CHANNEL_COUNT
- AUDIO_QUALITY_UNKNOWN/CD/HIRES_LEVEL1/LEVEL3 (the rest were
  already pub(super))
- QconnectServiceState.custom_device_name field

Test imports re-pathed to super::commands::* for the helpers that
moved with the report command.

Pure relocation. mod.rs: 3673 → 2865 lines. commands.rs: 847 lines.
All 37 qconnect tests pass.

cc
2026-04-27 17:24:22 -06:00
vicrodh b4a32aec5d refactor(qconnect): fix test module imports broken by submodule split
Steps 4-8 moved items into focused submodules but only verified `cargo
check` (lib only) — tests under #[cfg(test)] in mod.rs continued
referring to items via plain `super::FunctionName`, which only resolved
when the items were still in mod.rs.

This commit re-paths the test imports to point at their actual
submodules:

- decode_hex_channel, parse_subscribe_channels, default_qconnect_device_info
  → super::transport::*
- find_unique_renderer_id, refresh_local_renderer_id,
  QconnectFileAudioQualitySnapshot → super::session::*
- resolve_controller_queue_item_from_snapshots,
  resolve_queue_item_ids_from_queue_state, QconnectRemoteSkipDirection,
  resolve_core_shuffle_order, QconnectControllerQueueItemResolution
  → super::queue_resolution::*
- build_session_renderer_snapshot → super::session::*
- should_reload_remote_track → super::track_loading::*
- queue_state_needs_materialization → super::corebridge::*

Promoted decode_hex_channel and parse_subscribe_channels to pub(super)
in transport.rs, and should_reload_remote_track to pub(super) in
track_loading.rs so the tests can reach them across the submodule
boundary.

All 37 qconnect tests pass.

cc
2026-04-27 17:04:08 -06:00
vicrodh c9285a33e9 refactor(qconnect): extract device identity and transport config to transport.rs
Move 14 items that own QConnect transport setup, device identity, and
credential discovery into a dedicated transport submodule:

Device identity (pub(super)):
- default_qconnect_device_info / default_qconnect_device_info_with_name
- resolve_qconnect_device_uuid (with QCONNECT_DEVICE_UUID OnceLock)
- resolve_system_hostname, resolve_default_qconnect_device_name

Custom-device-name persistence (pub(super)):
- load_persisted_device_name, persist_device_name
  (qconnect_settings_db_path stays private to the submodule)

Transport config + credentials (pub(super) + private):
- resolve_transport_config (handles env-var fallbacks, qws/createToken
  auto-discovery, subscribe-channel parsing)
- fetch_qconnect_transport_credentials (private)
- normalize_opt_string, parse_subscribe_channels, decode_hex_channel
  (private)

Diagnostics (pub(super)):
- hex_preview (used by transport-event debug logs in mod.rs)

Constants moved to transport.rs (private to the submodule):
- DEFAULT_QCONNECT_DEVICE_BRAND/MODEL/TYPE/SOFTWARE_PREFIX
- VOLUME_REMOTE_CONTROL_ALLOWED
- QCONNECT_QWS_TOKEN_KIND, QCONNECT_QWS_CREATE_TOKEN_PATH

Constants kept in mod.rs but promoted to pub(super) so transport.rs
can borrow them: AUDIO_QUALITY_MP3, AUDIO_QUALITY_HIRES_LEVEL2.

mod.rs imports OnceLock and Uuid removed (now confined to transport).

Pure relocation. mod.rs: 4022 → 3673 lines. transport.rs: 386 lines.
Total qconnect/ submodules now: 8 (types, session, queue_resolution,
corebridge, track_loading, event_sink, transport, plus residual mod.rs).

cc
2026-04-27 15:48:30 -06:00
vicrodh 8391ef46df refactor(qconnect): extract QconnectEventSink impl to event_sink.rs
Move the Tauri-side event sink (struct TauriQconnectEventSink + the
QconnectEventSink trait impl + the apply_session_management_event
dispatcher) into a dedicated event_sink submodule.

This is the seam between the qconnect-app crate's event stream and
qbz's local state — every WS-driven event (renderer updated, queue
updated, command applied, session-management messages) flows through
on_event. Splitting it out makes the dispatch graph navigable
without scrolling through 4000+ unrelated lines.

apply_session_management_event handles 9 message types:
SESSION_STATE, ADD_RENDERER, UPDATE_RENDERER, REMOVE_RENDERER,
ACTIVE_RENDERER_CHANGED, RENDERER_STATE_UPDATED, VOLUME_CHANGED,
VOLUME_MUTED, MAX_AUDIO_QUALITY_CHANGED, LOOP_MODE_SET — plus three
post-dispatch hooks (apply_remote_loop_mode_to_corebridge,
sync_local_playback_for_renderer_ownership, sync_active_renderer_
projection).

mod.rs:
- TauriQconnectEventSink moved to event_sink module
- qconnect_now_ms marked pub(super) for cross-module access
- async_trait, qconnect_app::{QconnectAppEvent, QconnectEventSink}
  imports removed (now confined to event_sink.rs)
- The session/queue_resolution imports trimmed to only what mod.rs
  itself still references

Pure relocation. mod.rs: 4460 → 4022 lines. event_sink.rs: 425 lines.
Total qconnect/ submodules now: 7 (types, session, queue_resolution,
corebridge, track_loading, event_sink, plus the residual mod.rs).

cc
2026-04-27 15:42:05 -06:00
vicrodh 579aafbe2c refactor(qconnect): extract remote track streaming and load dedup to track_loading.rs
Move 7 items that own remote track loading (HTTP streaming, FLAC probe,
fallback download, and the load-attempt dedup window) into a dedicated
track_loading submodule:

- LOAD_ATTEMPT_DEDUP_WINDOW (5s constant)
- should_reload_remote_track (track-id-only reload predicate)
- load_remote_track_into_player (top-level load with streaming-then-
  download fallback)
- is_recent_load_attempt (dedup check against sync_state)
- ensure_remote_track_loaded (pub(super) — orchestrator called by
  corebridge::apply_renderer_command_to_corebridge and the V2
  play_track_handoff path)
- QconnectRemoteStreamInfo (FLAC probe result)
- stream_remote_track_into_player, probe_remote_stream_info,
  download_and_stream_remote_track, download_remote_audio

corebridge.rs now imports ensure_remote_track_loaded directly from
super::track_loading instead of going through mod.rs.

Pure relocation. mod.rs: 4777 → 4460 lines. track_loading.rs: 337 lines.
Total qconnect/ submodules now: 6 (types, session, queue_resolution,
corebridge, track_loading, plus the residual mod.rs).

cc
2026-04-27 15:05:19 -06:00
vicrodh 69ef5de5a0 refactor(qconnect): extract CoreBridge bridging logic to corebridge.rs
Move 6 functions that bridge cloud-emitted QConnect events to the
local CoreBridge audio engine into a dedicated corebridge submodule:

- queue_state_needs_materialization (dedup comparator)
- apply_remote_loop_mode_to_corebridge
- apply_renderer_command_to_corebridge (SetState/SetVolume/MuteVolume/
  SetLoopMode/SetActive handler — ~180 lines, the busiest function)
- sync_corebridge_remote_shuffle_projection (shuffle projection with
  identity-shuffle deferral; remains private to corebridge)
- materialize_remote_queue_to_corebridge (queue replication, ~210 lines)
- align_corebridge_queue_cursor

mod.rs deps that corebridge.rs reaches via super:: are now pub(super):
- PLAYING_STATE_PLAYING/PAUSED/STOPPED/UNKNOWN constants
- normalize_volume_to_fraction, qconnect_repeat_mode_from_loop_mode,
  model_track_to_core_queue_track
- ensure_remote_track_loaded (still in mod.rs, will move to
  track_loading.rs in a follow-up step)

corebridge.rs imports queue_resolution::* directly for the cursor and
order helpers, and session::is_peer_renderer_active for the echo-reset
gate.

Pure relocation. mod.rs: 5304 → 4777 lines. corebridge.rs: 565 lines.

cc
2026-04-27 15:02:14 -06:00
vicrodh 7ea9511c26 refactor(qconnect): extract queue cursor and order resolution to queue_resolution.rs
Move 18 items that own the protocol-side queue/cursor/order math from
mod.rs into a dedicated queue_resolution submodule:

Types (pub(super)):
- QconnectOrderedQueueCursor — Queue/Autoplay cursor abstraction
- QconnectRemoteSkipDirection — controller next/previous direction
- QconnectControllerQueueItemResolution — skip resolution outcome

Helpers (pub(super) when used by other submodules, fn otherwise):
- is_valid_ordered_queue_shuffle_order
- ordered_queue_cursors, queue_item_track_id_for_cursor,
  normalized_queue_item_id_for_cursor
- find_cursor_index_by_queue_item_id, find_cursor_index_by_track_id,
  find_cursor_index_by_track_id_before
- resolve_current_cursor_index_from_snapshots,
  resolve_controller_queue_item_from_snapshots,
  resolve_queue_item_ids_from_queue_state
- dedupe_track_ids
- resolve_remote_start_index, resolve_core_shuffle_order
- is_cloud_placeholder_current_queue_item,
  normalize_current_queue_item_id_from_queue_state

session.rs now imports cursor helpers directly from
super::queue_resolution::* instead of going through mod.rs.

Pure relocation. mod.rs: 5766 → 5297 lines. queue_resolution.rs: 460 lines.

cc
2026-04-27 14:57:54 -06:00
vicrodh a608ba2e06 refactor(qconnect): extract session topology and renderer snapshots to session.rs
Move 16 items from mod.rs to a dedicated session submodule:

Types:
- QconnectSessionState (pub) — session topology surfaced to frontend
- QconnectRendererInfo (pub) — renderer device descriptor
- QconnectSessionRendererState (pub(crate)) — per-renderer cached state
- QconnectFileAudioQualitySnapshot (pub(crate)) — audio quality dedup
- QconnectRendererReportDebugEvent (pub(crate)) — renderer report telemetry

Helpers (all pub(super)):
- refresh_local_renderer_id, find_unique_renderer_id
- normalize_active_renderer_id, is_peer_renderer_active, is_local_renderer_active
- sync_session_renderer_active_flags, ensure_session_renderer_state
- build_session_renderer_snapshot, build_effective_renderer_snapshot
- build_visible_queue_projection, queue_item_snapshot_for_cursor
- cache_renderer_snapshot

mod.rs deps that session.rs reaches via super:: are now pub(super):
- QconnectRemoteSyncState and all its fields
- QconnectOrderedQueueCursor
- ordered_queue_cursors, find_cursor_index_by_queue_item_id,
  find_cursor_index_by_track_id, normalize_current_queue_item_id_from_queue_state
- default_qconnect_device_info, resolve_qconnect_device_uuid

Pure relocation. mod.rs: 6109 → 5766 lines. session.rs: 372 lines.

cc
2026-04-27 14:51:33 -06:00
vicrodh e5c0261158 refactor(qconnect): extract wire types and request payloads to types.rs
Move 22 public types (QconnectConnectOptions, QconnectLifecycleState,
QconnectConnectionStatus, QconnectVisibleQueueProjection, the 20-variant
QconnectOutboundCommandType + 5 request payload structs, the 4 admission/
handoff types, the 8 device/session/queue payloads) into a dedicated
types.rs submodule. mod.rs re-exports via `pub use types::*` so external
callers (lib.rs invoke handlers, commands_v2/queue.rs) keep their import
paths.

Pure data and enum-mapping impls only. Two helper methods on
QconnectOutboundCommandType (to_queue_command_type,
requires_remote_queue_admission) become pub(super) since they are only
used from within the qconnect module tree.

mod.rs: 6371 → 6109 lines. types.rs: 273 lines.

cc
2026-04-27 14:17:19 -06:00
vicrodh fc5cd6efc7 refactor(qconnect): move qconnect_service.rs to qconnect/mod.rs
Pure rename to enable subsequent splits into focused submodules. No
behavior change: 34 invoke handler refs in lib.rs and 2 use statements
in commands_v2/queue.rs updated from qconnect_service:: to qconnect::.

Foundation for the qconnect_service.rs → qconnect/ module split: the
file is 6371 lines and mixes 12 distinct responsibilities (types,
session topology, event sink, queue resolution, corebridge bridging,
track loading, transport, Tauri commands, etc). Splitting into focused
submodules makes the area navigable for both authors and reviewers,
enables independent reasoning per concern, and creates a clean target
for the eventual qbz-qconnect-proto crate extraction.

cc
2026-04-27 14:14:12 -06:00
vicrodh 147bcbd715 fix(qconnect): eliminate first-track hiccup, prev/next bouncing, and shuffle drift
Bundle of related fixes addressing QConnect echo handling and shuffle
authority. All four converge on the same architectural principle:
qbz must not invent state when the cloud is the source of truth.

1. Remove should_force_remote_track_restart and its echo-driven reload

   The function fired whenever the cloud echoed SetState with
   target_position_ms=0 for the same track currently playing. The cloud
   re-emits this routinely on secondary field changes (next_track
   corrections, queue_item_id refresh) — every echo triggered a
   load_remote_track_into_player that interrupted in-progress audio.

   Track-change cases continue to load via align_corebridge_queue_cursor
   plus ensure_remote_track_loaded.

2. Reject echo seeks in apply_renderer_command_to_corebridge

   Authoritative seek-to-zero is only honored when the command targets
   a different track, or when local position is already near zero. If
   command.current_track matches playback_state.track_id and target<=1s
   while local_pos>2s, the seek is treated as echo and skipped. The
   prior 2s threshold was insufficient to absorb cloud round-trip during
   queue changes.

3. Drop has_loaded_audio gate from should_reload_remote_track

   playback_state.track_id is updated by the audio thread only after
   engine.append(source) succeeds. During the 1-3s buffer/decode window
   of an initial load, has_loaded_audio is false even though loading is
   in flight — the prior gate caused ensure_remote_track_loaded to
   redundantly re-trigger load on every echo SetState arriving in that
   window. This was the residual first-track hiccup.

4. Add load-attempt dedup across V2 play and ensure_remote_track_loaded

   New sync_state.last_load_attempt: Option<(track_id, Instant)> with a
   5s window. Two write sites:
     - play_remote_renderer_track_if_active when active_renderer_is_local
       (V2 frontend just dispatched a local play)
     - ensure_remote_track_loaded when it triggers its own load
   Read site: ensure_remote_track_loaded skips if last attempt for the
   same track was within the window. Eliminates redundant loads when an
   echo SetState arrives while a local play is still buffering.

5. Defer engine shuffle activation until cloud sends a valid order

   The cloud broadcasts shuffle toggles in two stages: first a
   QueueUpdated with shuffle_mode=true and shuffle_order=null, then
   ~400ms later a QueueUpdated with the computed shuffle_order. The
   prior code applied set_queue_with_order with shuffle_enabled=true on
   the first event, falling through to set_identity_shuffle_order_internal
   — qbz inventing a sequence (0,1,2,...) that diverges from the order
   the cloud is about to authorize. If the second event was delayed,
   dropped, or raced against the first, qbz drifted from peer clients
   (the long-standing "doble shuffle" symptom).

   Now: effective_shuffle_enabled = shuffle_mode && core_shuffle_order.
   is_some(). When the order is absent, the engine stays unshuffled and
   waits for the second QueueUpdated. sync_corebridge_remote_shuffle_
   projection short-circuits identically. Updated diagnostic log
   surfaces remote_shuffle_mode, shuffle_order_present, and engine_
   shuffle_enabled separately for future triage.

Tests: removed should_force_remote_track_restart test, simplified
should_reload_remote_track test to reflect the track-id-only contract.

cc
2026-04-27 14:02:58 -06:00
vicrodh dee987c1aa fix(player): gate gapless transition on prefetch attempted for current track
The isGaplessTransition predicate fired on every external track change
(QConnect renderer command, MPRIS, etc.) when qbz was the local
renderer, mis-classifying remote-driven track changes as gapless
transitions. The handler then ran onGaplessTransition every backend
tick (~1Hz) without updating currentTrack, leaving the UI stale and
spamming v2_next_track.

Added gaplessAttemptedForCurrent gate captured before the existing
null-out: only treat the event as a gapless transition when
gaplessAttemptTrackId matches the track currentTrack actually believes
is playing. External track changes fall through to the
"track changed externally" block which updates currentTrack properly.

cc
2026-04-27 14:02:32 -06:00
vicrodh 89651cae60 Merge chore/internal/orphan-files-deletion into pre-release 2026-04-26 23:15:34 -06:00
vicrodh 6cecb22cb1 chore(network): delete dead pub mod commands block in network/mod.rs
The inner `pub mod commands` block in network/mod.rs hosted three
unregistered Tauri commands (get_network_mounts_cmd,
check_mount_accessible, check_network_paths_batch) and was uncalled.
The non-command helpers in the parent module (get_network_mounts,
check_mount_accessibility, find_mount_for_path, parse_mount_info)
are also no longer referenced now that the commands are gone, but
they remain pub for potential future use without warnings.

- Remove the inner `pub mod commands { ... }` block.
- Drop the now-unused std::collections::HashMap import.

cc
2026-04-26 22:50:38 -06:00
vicrodh 2345bf7849 chore(commands): delete legacy commands module and inline helpers
src-tauri/src/commands/user_session.rs hosted three unregistered
#[tauri::command] entries (activate_user_session, deactivate_user_session,
get_last_user_id) plus two non-command helpers (init_type_alias_state,
teardown_type_alias_state) consumed only by session_lifecycle.rs.

- Move init_type_alias_state and teardown_type_alias_state into
  session_lifecycle.rs (their only caller) as plain pub helpers.
- Delete src-tauri/src/commands/user_session.rs and
  src-tauri/src/commands/mod.rs.
- Remove pub mod commands; from lib.rs — the legacy commands
  namespace is now empty.
- Refresh the activate_session doc comment to match the new layout.

cc
2026-04-26 22:47:11 -06:00
vicrodh 3b6cb3c51f chore(library): delete dead library/commands.rs
The 1,375-line library/commands.rs hosted only unregistered Tauri
commands (54 of them) plus a path-compat pub use of state types.
Every #[tauri::command] in the file is V2-replaced and uncalled.
The state-types pub use was the only external surface and is
already exported canonically from library/mod.rs.

- Delete src-tauri/src/library/commands.rs entirely
- Drop pub mod commands; from library/mod.rs
- Update session_lifecycle.rs to use crate::library::LibraryState
  (the canonical path) instead of crate::library::commands::LibraryState
- Refresh the doc comment in library/state.rs to reflect the
  post-cleanup layout

cc
2026-04-26 22:44:34 -06:00
vicrodh df1583a727 chore(offline_cache): extract purge helper and delete dead commands.rs
All 17 #[tauri::command] entries in offline_cache/commands.rs are
unregistered (V2 owns the live cache commands) and uncalled. The only
surviving consumer was session_lifecycle.rs reaching for
purge_all_cached_files (a non-command helper).

- Move purge_all_cached_files to a new offline_cache/purge.rs module
  (drops the LibraryState path through library::commands; uses the
  canonical crate::library::LibraryState directly)
- Delete offline_cache/commands.rs along with its dead siblings:
  cache_track_for_offline + post_process_cached_track,
  is_track_cached, get_cached_track_path, get_cached_track,
  get_cached_tracks, get_offline_cache_stats, remove_cached_track,
  clear_offline_cache + cleanup_empty_folder, set_offline_cache_limit
  + evict_if_needed, open_offline_cache_folder, open_album_folder,
  open_track_folder, check_offline_root_mounted, validate_offline_path,
  move_offline_cache_to_path, detect_legacy_cached_files (the command
  shim — the helper of the same name lives in migration.rs and is
  still consumed by V2), start_legacy_migration,
  sync_offline_cache_to_library + SyncResult.
- Update session_lifecycle.rs to import from offline_cache::purge.

cc
2026-04-26 22:42:28 -06:00
vicrodh 1a138d8e2f chore(reco_store): rename commands.rs to helpers.rs and drop dead commands
The reco_store/commands.rs module no longer hosts any registered Tauri
commands. Its surviving content is purely helper logic consumed by V2
home recommendations in commands_v2/library.rs.

- Rename reco_store/commands.rs -> reco_store/helpers.rs
- Drop the unregistered/uncalled #[tauri::command] entries
  (reco_log_event, reco_train_scores, get_playlist_suggestions,
  reco_backfill_genres, reco_needs_genre_backfill) and their dead
  scoring helpers (build_scores, build_track_entries, decay_factor,
  current_timestamp, etc.) along with RecoTrainStats / RecoScoreCounts
  / BackfillStats structs that only those commands referenced.
- Update commands_v2/library.rs to import from
  crate::reco_store::helpers::* (still uses get_home_seeds_internal,
  resolve_albums, resolve_tracks, resolve_artists, album_to_card_meta).

cc
2026-04-26 22:39:05 -06:00
vicrodh 3f819d71df chore(cast): delete dead cast and dlna commands modules
The Tauri commands in cast/commands.rs and cast/dlna/commands.rs are
unregistered (handler list is V2-only) and uncalled. The CastState /
DlnaState path-compat re-exports are also unused (canonical paths are
crate::cast::CastState and crate::cast::dlna::DlnaState). Remove the
files entirely and drop pub mod declarations.

cc
2026-04-26 22:35:14 -06:00
vicrodh c57acbda4c Merge refactor/internal/api-server-v2-migration into pre-release 2026-04-26 22:26:02 -06:00
vicrodh dc16e71617 chore(legacy): delete src-tauri/src/commands/playback.rs
The last consumer (api_server REST handlers) was migrated to
commands_v2::v2_play_track / v2_seek / v2_set_volume in the previous
commit, so the file's three remaining #[tauri::command] functions
(play_track, seek, set_volume) plus the unregistered prefetch_track,
get_playback_state, and all helper symbols (parse_quality,
limit_quality_for_device, PlayTrackResult, StreamInfo, download_audio,
get_stream_info, extract_audio_format_from_header, download_and_stream,
spawn_prefetch, prefetch constants, PREFETCH_SEMAPHORE) had zero
remaining callers.

Drops the `pub mod playback;` and `pub use playback::*;` lines from
commands/mod.rs. Only user_session.rs remains in the legacy commands
module.

cc
2026-04-26 22:00:06 -06:00
vicrodh 2d60f50140 refactor(api): migrate api_server playback to V2 commands
Switches the REST endpoints serving the PWA remote control from the
legacy commands::playback::* helpers to their V2 equivalents:

- play_queue_track / play_queue_index / play_album:
    commands::playback::play_track -> commands_v2::v2_play_track
- seek: commands::playback::seek -> commands_v2::v2_seek
- set_volume: commands::playback::set_volume -> commands_v2::v2_set_volume

The HTTP contract (paths, methods, payload shapes, response codes,
emit_playback_update side-effects) is unchanged. Behaviour gains the
V2 features that legacy lacked (CMAF cache hits, ALSA hardware
sample-rate guard, manual-offline-mode awareness) because the PWA
remote now flows through the same playback path as the desktop UI.

Drops the `commands` import from this module (now only `commands_v2`
is used) and adds the State acquisitions needed by v2_play_track:
CoreBridgeState, OfflineState, RuntimeManagerState.

cc
2026-04-26 21:58:16 -06:00