The P1-4 remote-queue routers (clear/remove/reorder) existed and were
unit-tested but their call sites in +page.svelte were never wired, so
reorder/remove/clear kept diverging from the renderer when a Qobuz
Connect peer owns playback.
- handleClearQueue: route to clearQconnectQueueIfRemote() first when a
peer renderer is active; return before the local clearQueue() to avoid
double-mutation.
- handleRemoveFromQueue: map the local upcoming index to the renderer's
stable queue_item_id via the QConnect queue snapshot (slice past the
current track, index into the upcoming portion, corroborate by
track_id), then route to removeQconnectQueueItemsIfRemote(). Falls
through to the local invoke if the id can't be resolved unambiguously.
- handleQueueReorder: left local-only by design (DONE_WITH_CONCERNS).
The remote queue_reorder_tracks command needs the full target order of
queue_item_ids, which can't be reconstructed reliably from a local
upcoming from/to index pair (no per-row queue_item_id, ambiguous
duplicate track_ids, renderer shuffle/autoplay ordering). Documented
inline; sending wrong ids would silently corrupt the renderer queue.
Gate is qconnectPeerRendererActive (matching seek/volume/play-track
handlers), not bare transport-connected, so local playback under a
connected-but-not-peer session keeps mutating the local store.
cc
Add volume_remote_control to QconnectRendererInfo plus the
renderer_allows_remote_volume predicate (absent => allowed, only an explicit
non-1 value disables). Parse it from device_info.capabilities in the ADD and
UPDATE renderer paths, and short-circuit set_volume_if_remote when the active
renderer disallows remote volume (returns handled no-op so the frontend does
not fall back to local). Frontend surfaces volume_remote_control in the session
snapshot, derives a unified volume lock + reason, guards handleVolumeChange,
and shows a distinct player.volumeLockedRemote tooltip (added to all 5 locales).
cc
Consumer maps PlaybackError -> handleSkipForward for streamable/network/
not-found failures on the current queue item; toast otherwise. Predicate
shouldAutoSkipOnPlaybackError unit-tested. i18n keys added in 5 locales.
cc
A playing active-peer renderer that goes silent left the controller UI
stuck on stale 'playing' (no equivalent of the webplayer's 12s
rendererLost timer). Add a generation-guarded 12s watchdog keyed on the
active peer renderer in the RENDERER_STATE_UPDATED handler: reset on every
update, arm only while playing_state==PLAYING, disarm on
pause/stop/active-change/disconnect. On expiry freeze the projection to
UNKNOWN and emit qconnect:renderer_unreachable; Svelte shows an error
toast (rendererUnreachable, all 5 locales). Reuses the
freeze-projection-to-unknown helper from the ACTIVE_DISCONNECTED path.
cc
The wire 'status' field on RENDERER_STATE_UPDATED was decoded into the
adapter payload (decoder.rs:801) then dropped — a graceful peer
disconnect was invisible and the controller kept showing stale 'playing'.
Add a RendererStatus enum (UNKNOWN/UNRECOGNIZED -> INACTIVE collapse) and,
on ACTIVE_DISCONNECTED for the active remote renderer (id != -1), freeze
the projection to UNKNOWN and emit qconnect:renderer_disconnected. Svelte
surfaces it as an error toast (rendererDisconnected, all 5 locales). Pure
consumer-side: no protocol change.
cc
The TransportConnected handler pushed the live queue via loadQconnectQueue
with no source check, shipping local/Plex row ids as Qobuz ids. The frontend
display queue (getQueueState) carries no `source`/`is_local`, so wire the push
to getBackendQueueState() whose tracks DO carry them (v2_get_queue_state maps
source per track in commands_v2/queue.rs). Route the ordered snapshot
([history, current, upcoming]) through assessQconnectQueueSync: refuse the
whole push on a mixed queue (no silent subset) and toast the user via the new
qconnect.mixedQueueNotCast key (all five locales). Lock the refuse-not-subset
contract with a unit test; page wiring covered by svelte-check + manual verify.
cc
The offline-skip recursion walked the entire remaining queue synchronously,
desyncing the logical queue from the still-playing engine. Fixes:
- The gapless-transition target is the track the engine is already playing;
never run the availability-skip branch for it.
- isTrackAvailable now counts the in-memory/disk cache (new v2_is_track_cached
command), not just downloaded copies.
- Cap consecutive offline skips and play-failure auto-skips at 5 in both
+page.svelte and playbackService.ts.
cc
Clicking a track inside an expanded Mixtape/Collection item now plays
it. Qobuz albums and playlists build the queue directly; Plex and
local-library albums delegate to the backend collection-item resolver,
then jump the queue cursor to the clicked track.
cc
Solves the long-running performance complaints (especially Optimus
laptops where the dGPU silently hijacks WebKit). The end-to-end shape:
- graphics_detection enumerates physical GPUs from /sys/class/drm and
cross-references EGL vendor JSONs under /usr/share/glvnd/egl_vendor.d
to expose only stacks the running process can actually use. Works
inside Flatpak/Snap because /sys is still mounted read-only and the
sandbox-visible EGL vendor JSONs are the right thing to read.
- preferred_gpu column in graphics_settings.db (string: auto, integrated,
discrete, software, or a specific PCI slot id). Migrated with
ALTER TABLE ... DEFAULT 'auto'.
- env_vars_for_preferred_gpu returns Set/Unset actions applied at startup
before WebKit init. Integrated/AMD path explicitly unsets
__NV_PRIME_RENDER_OFFLOAD so KDE/Optimus sessions that pre-set it to 1
no longer override the EGL pin and yank rendering back onto NVIDIA.
- Auto resolver: on hybrid systems (iGPU + dGPU both usable), Auto pins
to the iGPU. Single-GPU and no-GPU systems remain no-op. The dropdown
surfaces what Auto resolved to ("Auto (Intel Graphics)") so the user
sees the decision without picking explicitly.
- boot_watchdog writes a pending.json before WebKit init listing the
risky knobs in effect. Frontend calls v2_mark_boot_succeeded after
first paint to clear it and promote to last-good.json. If the next
launch sees pending without a success ack, it blames matching fields,
reverts them in-DB, and increments per-knob failure counters; two
consecutive failures lock the knob until the user clears it.
- Settings > Graphics gets a Rendering GPU dropdown (between HW accel
and DMA-BUF) and a crash-recovery banner that lets the user re-enable
any auto-disabled knob. Restart-required affordance shared with the
other risky toggles.
- i18n keys for the dropdown, banner copy, and autoWithTarget label
across all five locales.
cc
JavaScript bitwise shift operates on 32-bit signed integers, so
`1 << 48` is `1 << (48 % 32)` = `1 << 16` = 65536. Every Qobuz track
id sits above that, so the existing `id >= EPHEMERAL_ID_FLOOR` checks
were classifying every Qobuz track as ephemeral and disabling the
favorite / playlist / mixtape / collection actions for them — most
visibly in the immersive player favorite button, but also silently
stripping Qobuz tracks from the persisted session queue.
Five places shared the same broken constant, all now use `2 ** 48`
(281474976710656) — the actual ephemeral id floor defined in
src-tauri/src/ephemeral_library/mod.rs:
- src/routes/+page.svelte (two metadataActionsDisabled checks for
the immersive player and now-playing bar, plus the session-save
EPHEMERAL_ID_FLOOR filter).
- src/lib/components/QueuePanel.svelte (isEphemeralQueueTrack guard
that hides favorite / playlist / track-info actions for queue
rows).
- src/lib/stores/playerStore.ts (isEphemeralTransition flag in the
gapless carve-out and CUE virtual-track position translation).
- src/lib/components/views/LocalLibraryView.svelte (close-folder
cleanup check — only ephemeral folders should wipe the queue +
now-playing slot when closed).
Each call site keeps a short comment naming the trap so future edits
don't reach for the same bitwise expression.
cc
Today the startup orchestration skips the local session restore whenever
qconnectRuntimeActive — defensive against the visual flicker of seeing
the last local queue and then watching QConnect overwrite it. That left
QConnect users staring at "No track playing" while the WebSocket
bootstrapped, and the queue was effectively unrecoverable if QConnect
failed to push state.
Inverts the policy per #315: always restore the local snapshot. When
QConnect is bootstrapping at launch, the restore is held back behind a
1s flicker guard timer. The QueueUpdated event listener cancels the
timer if the remote queue snapshot arrives first — the user sees only
QConnect state, no flicker. If 1s elapses without a remote snapshot
(slow network, server hiccup, controller offline), the timer fires and
local restore proceeds so the user isn't stuck.
Two related wires:
- restoreLastView runs unconditionally now (view restore is unrelated
to playback source, shouldn't be gated on QConnect).
- The QConnect listener also calls debouncedFullSessionSave after a
successful QueueUpdated sync, covering the case where QConnect
reorders/replaces upcoming tracks while the current_track stays the
same — the track-change effect wouldn't fire, and without this hook
the local snapshot would drift behind remote.
Acceptance from issue #315: queue persistence on every mutation
(prior commit), QConnect state read at startup (already wired via
bootstrap_remote_presence → AskForQueueState → QueueStateChanged),
QConnect wins on divergence (cancel-guard makes this the typical
path), local-only path unchanged when QConnect is off (else branch).
The auto-advance / delete-timing guard logic in corebridge.rs and
commands_v2/playback.rs is untouched — the change lives in the route
component's restore orchestration, not in the QConnect runtime.
cc
The PersistedQueueTrack struct already had streamable and parental_warning
fields but the load path hardcoded streamable=true and parental_warning
=false because the columns didn't exist in queue_tracks. source_item_id
_hint was missing entirely.
Adds the three columns via the same idempotent ALTER-TABLE pattern the
existing migrations use, persists them in save_session, and reads them in
load_session. PersistedQueueTrack gains source_item_id_hint as an
Option<String> with serde default so saves from older builds deserialize
cleanly.
User-visible effect: a restored queue now keeps explicit-content badges,
unstreamable visual state, and Mixtape/Collection source-item ids
matching the live queue instead of resetting to defaults at every
launch. Backwards-compatible: columns get safe defaults for older rows
(streamable=1, parental_warning=0, hint=NULL) so users upgrading from
1.2.12 don't see a regression.
cc
The local session restore today saves the full queue only on track-change
(debounced 2s), pause, clear, the 15s playback tick, and app-close. Pure
queue mutations — add/addNext/setQueue/clearQueue/moveQueueTrack/
removeAfter/stop-after-consume — left the persisted queue stale until the
next track boundary, which meant a crash mid-album lost any tracks
queued after the playing one.
queueStore exposes registerQueueMutationObserver so the route component
can plug debouncedFullSessionSave into the mutation path without the
store knowing about session-save internals. Each mutator that funnels
through syncQueueState now also calls notifyMutation, the observer
coalesces via the existing 2s debounce, and the SQLite write happens on
the same path the track-change save already uses (single source of
truth, idempotent on rapid mutations).
The cursor-only mutators (next/previous/playQueueIndex) intentionally
do NOT notify — they are already covered by the track-change effect at
+page.svelte:5055 which saves on current_index change.
cc
subscribeLyrics fires per rAF tick (~60Hz with extrapolation). The
previous version wrote all six $state slots unconditionally, which
re-triggered the lyricsSidebarLines $derived (a .map allocating N
objects per call) every tick even though lyricsLines itself only
changes per-track. Adding reference/value guards short-circuits the
fan-out for unchanged fields — the per-tick path now only writes
lyricsActiveProgress (and sometimes lyricsActiveIndex).
Listen for the backend audio:init-failed event and show a user-facing
error toast (8s) with the backend-provided cause. Mirrors the
audio:device-missing listener pattern alongside it. Surfaces the
silence when macOS CoreAudio init fails, instead of leaving the user
guessing why playback didn't start.
Comprehensive set of changes that make immersive mode usable on
machines without hardware-accelerated GPU compositing (Wayland NVIDIA
with the broken driver path, software-rendered llvmpipe, VMs, etc.).
Detection lives in `graphicsState.ts` — when `bootstrapGraphicsState()`
resolves and `hardware_accel_enabled` is false, we add an
`html.no-hwaccel` root class. Every CPU-mode rule below gates itself
on that class so GPU users see zero change.
Chrome-level work (ImmersivePlayer wraps every panel):
- Blanket CSS rule on `.immersive-player` and descendants kills
every `transition`, `animation`, `box-shadow`, and `text-shadow`.
Box-shadows with 32-60px blur radii were the dominant per-frame
cost — confirmed game-changer when stripped (user-tested on
Visualizer with `0 8px 32px + 0 20px 60px` halo around the
artwork).
- `.player-bar`, `.window-menu`, `.tabs`, `.viz-dropdown`,
`.viz-submenu`, and the quality-badge backdrop become opaque
`#000` and drop `backdrop-filter` (40px / 16px / 12px blurs).
- Whitelisted animation exceptions: the 4-bar equalizer in
Coverflow's now-playing indicator (4 x 3px scaleY, basically
free) and `fadeInUpLite` for LyricsFocus line changes
(opacity + transform only, no `filter: blur`).
Per-panel LOW_PROFILE flag (captured from `isHardwareAccelEnabled()`):
- Oscilloscope: skip `shadowBlur: 8` glow, fake with fat-underlay
+ thin-opaque stroke pair; force dpr=1.
- Lissajous: skip shadowBlur: 6 with same trick, trail 8 -> 3
frames, drop the 85-fillRect-per-frame dot cloud, hide the
blurred watermark, dpr=1.
- EnergyBands: drop per-band energy-driven shadowBlur (up to
~20px x 5 rings); halo-arc + ring trick; dpr=1.
- Visualizer: taller cubes (14px vs 6px, half the count per bar);
drop the per-cubelet gradient entirely so the whole frame uses
a single `fillStyle`; dpr=1.
- Coverflow: snap side-cover transitions (only `.center` keeps
its 500ms transform); neutralize hover transforms on side
covers (rotateY + scale 1.05 forces re-rasterization under
software perspective); side covers go to opacity:1 so they
don't pay alpha compositing.
LyricsLines split mode in immersive: restore line transitions for
opacity, color, and transform (all compositor-only). Skip the
font-size transition because that one forces re-layout per frame.
New per-panel enable/disable store (`immersivePanelsStore.ts`):
- localStorage-backed `Record<ImmersivePanelId, boolean>`.
- In CPU mode the five inherently-heavy panels default to
disabled: AlbumReactive, NeonFlow (Laser), TunnelFlow,
CometFlow, TransientPulse. These rely on shaders, particle
systems, or dense per-pixel filter passes that simply cannot
work in software compositing.
- `applyCpuModeDefaultsIfNeeded()` re-applies defaults after
`bootstrapGraphicsState()` resolves, so first-launch CPU users
don't have to relaunch to see the heavy panels hidden. Wiring
lives in `bootstrap.ts` to avoid a circular import between
the store and graphicsState.
- ImmersiveHeader filters the visualizer dropdown and Neon
submenu by enabled state; if all three Neon variants are off,
the submenu item disappears entirely. The `Visualizer` main
tab falls back to the first enabled viz option instead of
hardcoded spectral-ribbon.
First-time CPU-mode warning modal (`CpuModeWarningModal.svelte`):
- Built on ModalLite. Triggered from `+page.svelte` via $effect
when immersive opens, HW accel is off, and the user has not
dismissed the warning before. Disarmed for the rest of the
session after fire.
- "Don't show again" checkbox writes
`qbz-cpu-mode-warning-shown-v1` to localStorage when checked.
- Explains which panels are hidden and points to Settings ->
Appearance -> Composition and -> Immersive Panels.
Settings UI: new "Immersive Panels" collapsible section above the
existing "Immersive FPS" section in Settings -> Appearance. Per-panel
toggle, with a GPU badge + warning paragraph on the five heavy panels
when running in CPU mode.
QualityBadge unification (out of CPU-saver scope, addressed in same
branch by user request): every immersive callsite that previously
used the reactive `QualityBadge` (compact or full) now renders
`QualityBadgeStatic` — the same component used in Collection /
Mixtape detail lists. Sixteen callsites total: nine canvas panels
(compact mode), five center-content panels (full mode), plus
ImmersivePlayer split mode and ImmersiveControls. `QualityBadgeStatic`
gains `text-align: start` on its `.badge-text` block so it renders
correctly inside parents that center their content (VisualizerPanel,
StaticPanel, etc.).
i18n: new keys for `settings.appearance.immersivePanels.*` (title,
summary, desc, cpuWarning, heavyTooltip, warningModal.{title,body,
hint,dontShowAgain,acknowledge}) across en/es/de/fr/pt.
cc
Two perf agents independently identified the same dominant cost behind
the 2-3s modal-open latency on non-maximized windows: 8 modals declared
at the top of `+page.svelte` (PlaylistModal, PlaylistImportModal,
FolderEditModal, AboutModal, QualityFallbackModal, KeyboardShortcuts,
KeybindingsSettings, LinkResolverModal) were always mounted, with only
their inner templates gated by `{#if isOpen}`. Their `<script>` blocks
ran for the lifetime of the app and their reactive declarations
re-evaluated on every parent tick — and the parent ticks at ~4Hz during
playback via `playerStore.notifyListeners()`.
PlaylistModal alone is 1,420 lines / 18 reactive declarations. Sum
across 8 modals × ~4Hz × the +page.svelte reactive graph (188+ $state)
explains why mounting any *new* component (Discovery's customize modal,
the back-to-top button) raced with reactive churn and landed seconds
after the user clicked.
Fix: wrap each modal at the mount site in `{#if isOpen}`. The inner
component instance now only exists while the modal is open. Two-line
change per modal, zero API change. The `isOpen` prop on each modal is
left in place so the modal's internal close logic (focus mgmt, ARIA,
close animation) keeps working unchanged.
Also drops the 64px Gaussian box-shadow on `ModalLite.svelte` that I'd
added one commit prior. Under software compositing the blur radius
re-rasterizes whenever anything beneath repaints, defeating the whole
point of removing the overlay. Replaced with a flat composite shadow
(1px edge + 16px small-offset) — reads as "floating" without the
per-frame Gaussian cost.
Documents the pattern in `qbz-nix-docs/qbz-adr/ADR-003-conditional-
mount-pattern.md` so the same anti-pattern can be swept across the rest
of the codebase systematically. The ADR identifies search heuristic
(`<X isOpen={...} />` outside `{#if}`), the cost mechanism, and the
roadmap touchpoints (ModalRouter extraction from +page.svelte) where
the rule should be applied during decomposition.
cc
The previous `{#if showGlobalBackToTop}` block mounted/unmounted a
`position: fixed` button (with box-shadow) every time the user scrolled
past the 800px threshold. On 4K under software compositing the
unmount path (going up across the threshold) registered as a visible
hiccup — the user perceived it as a stutter halfway through scrolling
back to the top.
The button is now always mounted. Visibility is driven by a `.hidden`
class with `opacity: 0; pointer-events: none` and a 150ms opacity
transition. `tabindex` and `aria-hidden` reflect the boolean for a11y.
Also rewrites the scroll listener: previously each scroll event wrote
to a `$state<number>` and triggered a `$derived` recompute (60-120
times/second during active scrolling). Now the listener
rAF-coalesces and only assigns when the threshold actually flips.
Default scroll cost drops from "per-event reactive churn" to
"one boolean flip per threshold cross".
cc
Establishes the skeleton for a Discovery rebuild under "cero efectos"
constraint after a perf debug session confirmed the original home view's
vertical scroll cost was structural and accumulative, not a single
offender. Spec: qbz-nix-docs/specs/2026-05-11-discovery-v2-clean-room.md.
Adds:
- src/lib/discovery-v2/AlbumCardLite.svelte: 5-element card (cover,
title, artist, permanent play button). Cero `transition`, cero
`:hover` paint, cero `will-change`, cero `backdrop-filter`.
- src/lib/discovery-v2/DiscoverySection.svelte: titled inline CSS grid
of cards + "See all" link. No `overflow-x: auto` carousel — sections
wrap onto rows naturally.
- src/lib/discovery-v2/DiscoveryView.svelte: top-level. Same Props as
`HomeView.svelte` for drop-in mount swap. Toolbar with three tab
buttons (Home / Editor's Picks / For You) on the left and a slot
for the genre selector on the right — fixed 56px height, static
(no `position: sticky`), part of the main paint pass.
- i18n keys `discovery.{comingSoon,seeAll}` across en/es/de/fr/pt.
Swaps the mount in `src/routes/+page.svelte:5826` from `<HomeView>` to
`<DiscoveryView>`. `HomeView.svelte` stays in the tree as rollback. Real
sections will be wired incrementally in follow-up commits, each measured
against the perf baseline established by the scripts/measure-cpu-window.sh
tooling.
cc
Reverts merge f7623154 which built macOS support on top of the variant
rendering infrastructure introduced by titlebar-mode-selector. With the
variant system gone (previous revert), PR #406's changes (class:macos,
stripped strip with traffic-light reservation, macOS-aware drag bands)
reference dead architecture.
Most of PR #406 was already removed by the previous revert via file
checkouts; this commit cleans up the residual macOS-specific cosmetics
in Sidebar.svelte and +page.svelte (padding-top 36→32, drag-region
height 32→28, macos main-content padding restored to generic rule).
PR #407 (sticky home header fix, ac627d43) is preserved — independent
from titlebar work, only touches HomeView.svelte.
cc
Reverts merge 1be46f1a which introduced the 4-mode titlebar enum
(qbz/system/plasma/hidden), variant-aware rendering (full/stripped),
GDK_BACKEND=x11 coercion for Plasma, and per-mode mounting in +page.
The "stripped strip mounted alongside Plasma SSD" architecture caused
all titlebar modes to render incorrectly in the production binary —
empty space at top, separation gap above the now-playing bar, controls
visible without a backing strip. Reverting restores v1.2.10 behavior:
two booleans (use_system_titlebar + hide_title_bar), no variants, no
GDK coercion. Plasma support is dropped pending a less brittle redesign.
Conflicts in SettingsView.svelte resolved by taking the pre-merge side
for the four titlebar UI blocks; non-titlebar additions in this cycle
(Discord RPC settings, reserve-DAC, offline cache nav) preserved.
cc
Library-bound actions (favorite, add-to-playlist, track info) require a
DB row to operate; ephemeral tracks live only in memory and would 404
on use. Surface them disabled rather than removed so the affordance
stays discoverable in the chrome — users still see the icon, the
tooltip explains why it's inert.
Surfaces wired
- NowPlayingBar: + and ♥ disabled when active track id >= 2^48.
- QueuePanel: now-playing ♥ + per-track Add to Playlist + Track Info
in both queue and history menus disabled per-row by id range.
- ImmersiveControls + PlayerControlsCompact: ♥ disabled.
- ImmersivePlayer: passthrough prop to PlayerControlsCompact.
- MiniPlayerFooter / MiniPlayerWindowControls: no metadata actions in
these surfaces, nothing to gate.
Detection is by id range (>= EPHEMERAL_ID_FLOOR), not by source string.
The setQueueForLocalTracks mapping rewrites `source: 'ephemeral'` to
`'local'` before the track reaches the queue, so a string-equality
check would never fire — id range matches the rest of the ephemeral
codebase (gapless carve-out, CUE boundary, seek offset, queue/now-
playing wipe).
Tooltip on the disabled state uses the new
`actions.unavailableForEphemeral` key, translated for en, es, de, fr, pt.
cc
Two cross-cutting plumbing changes the ephemeral folder pane needs to
behave correctly without touching the rest of the playback paths.
playerStore: gapless transition detection used to require event.is_playing
to be true at the moment the track_id changes. The ephemeral file-data
swap (one buffer of bytes replacing another) briefly reports is_playing
false during the engine restart, and that transient frame arrives BEFORE
the player can flip the flag back. Without a carve-out the event was
mis-classified as an external track change, the queue's current_index
hadn't advanced (it's a separate state machine), and the UI froze on the
previous track. Streaming playback paths don't see this race because
their buffer transition keeps is_playing pinned. Carve-out is scoped to
event.track_id >= EPHEMERAL_ID_FLOOR so Qobuz/Plex/library are untouched.
routes/+page.svelte: session save now strips ephemeral tracks from the
queue snapshot and clears current_index if it pointed at one. Ephemeral
ids aren't stable across processes (the cache rebuilds from scratch on
each launch), and even if they were, the playback path 404s if session
restore fires before the folder rehydrates. The folder-path persistence
already brings the pane back; the queue can come back via re-clicking.
Also: gapless transition handler logs the advance/expected ids and the
match flag, plus runs a defensive syncQueueState on success — that's
belt-and-suspenders for paths where the queue's view of "current" lags
the player's gapless transition by a tick (e.g. ephemeral, where the
queue advance and the player swap are independent state machines).
cc
Calls discordRpcService.initDiscordRpc() once from +page.svelte onMount so
the backend state matches the persisted toggle and the playerStore subscriber
is registered when the user has opted in.
Refs #336.
cc
Add Album.parental_warning and Album.artists (with the new AlbumArtist
struct) to qbz-models so the /album/get response surfaces both fields.
Mirror them in the QobuzAlbum / AlbumDetail TypeScript types and
populate featuredArtists (filtered to non-main contributors) and
parentalWarning in convertQobuzAlbum. Wire onFeaturedArtistClick from
the route so featured artist names route to their discography.
cc
Wires the new OfflineCacheManagerView into +page.svelte's view switch with
onBack returning to settings, onGoToAlbum delegating to handleAlbumClick,
and onGoToFavorites returning to the user's last favorites tab. Adds
'offline-manager' to the ViewType union so the new branch type-checks.
Excludes 'offline-manager' from last_view persistence by routing it through
getSessionFallbackView('offline-manager') -> 'settings' (its entry point),
guarding against Phantom CSS Error Cause 3 on rehydration when the cache DB
is not yet ready at first paint.
cc
Frontend (62 components, ~64 rules):
- Replace hardcoded `color: white` / `#fff` with `var(--btn-primary-text)`
in every rule that paints text on `var(--accent-primary)`. Covers in-rule
cases (BulkActionBar, GenreFilterButton, KeybindingsSettings, settings
views, login, modal primary buttons, wizard steps, etc.) plus descendant
selectors (`.parent.selected .child`).
- Semi-transparent rgba(255,255,255,X) over selected accent rows now use
`color-mix(in srgb, var(--btn-primary-text) X%, transparent)` so the mute
level inherits the per-theme token color.
Theme tokens in app.css (--btn-primary-text fixes):
- rose-pine-dawn: #faf4ed → #575279 (was the bg color, ratio 2.60 FAIL;
now 5.71 AA against coral accent)
- kurosaki: #fbf9f2 → #262424 (was the bg color, ratio 1.76 FAIL;
now 8.33 AA against gold accent)
- snow-storm: #eceff4 → #2e3440 (was the bg color, ratio 3.50 AA-Large;
now 6.13 AA against blue accent)
- stratego: #0a0a0a → #ffffff (visual choice; 4.14 AA-Large is
acceptable for button labels and matches palette intent)
Auto-theme generator (src-tauri/src/auto_theme/generator.rs):
- pick_btn_text_for_accent_set(): WCAG-aware FG selection that holds
across accent / hover / active states. Replaces the broken
`luminance < 0.5` threshold which produced 2.0:1 ratios on mid-luminance
accents (Catppuccin lavender, Dracula purple, rose-pine coral).
- ensure_text_contrast_target(text, bg, is_dark, target): generalize the
iterative contrast helper to accept a target ratio. Apply to
text_secondary (4.5) and text_muted (3.0).
- Add --btn-danger-text and --btn-warning-text tokens via
pick_btn_text_for_bg for future status-button consumers.
Misc fixes:
- SettingsView.svelte: extract initialTitlebarMode to const, avoids the
Svelte 5 state_referenced_locally warning.
- QueuePanel.svelte: wrap .stop-after-marker in :global() (the class is
forwarded to a Svelte component child, not a native element) and fix
var(--accent) typo to the correct var(--accent-primary).
Tests: 13 generator tests pass (8 prior + 5 new). svelte-check 0 errors,
31 warnings (down from 33).
cc
If the user has search-in-titlebar off AND no titlebar-nav items
enabled, plasma mode would render an empty 42px strip below the KWin
SSD. Gate shouldShowTitleBar() in plasma on whether either is enabled.
+page.svelte's search-location and titlebar-nav subscriptions now
also recompute showTitleBar so the layout reflows immediately when
the user toggles those settings.
cc
The +page.svelte mount gate now reads shouldShowTitleBar() so layout
reacts to mode changes. Sidebar reads the effective search-location
and titlebar-nav getters so search and nav fall back to the sidebar
in system/hidden modes regardless of user pref. miniplayerService
reads getMode() instead of the removed legacy boolean.
cc
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
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
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
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
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
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
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
Path C: legacy command called client.get_releases_grid for the
artist detail view's release-type pagination grid. No existing
v2 equivalent, so add v2_get_releases_grid via QbzCore ->
QobuzClient::get_releases_grid directly.
cc
Previously the prop was named isQobuzConnectConnected, which suggested
transport-up semantics, but after 23658b05 it carries the user-intent
toggle state (true during Connecting and Reconnecting too). Rename to
match what the value actually means, removing the chance of a future
reader misreading the prop as a "safe to send commands" gate.
No behavior change.
Refs: #358
cc