Cherry-picked from PR #377 (thanks @Mar0xy) — Node-only portion, no
Discord IPC additions:
- 4 workflows (release-{linux,linux-aarch64,macos,flatpak}.yml) now
install Node 24
- packaging/flatpak/com.blitzfc.qbz.yml: SDK extension node20 → node24,
build append-path and npm_config_nodedir aligned
Forcing function: @icons-pack/svelte-simple-icons@7.x already in use
declares engines.node >=24, so Node 20 produces an EBADENGINE warning
in CI. Node 20 also reached EOL on 2026-04-30. Node 24 has been LTS
since October 2025 (~7 months of maturity).
Discord IPC permissions and qbz-wrapper script from #377 were
intentionally NOT cherry-picked — there's no Discord RPC feature in
QBZ today (#336 is still planned but not implemented), so adding the
sandbox permission and the launch wrapper now would expand the
attack surface for nothing. They'll land together with the actual
feature when it ships.
cc
Adds full multi-select coverage to the local library album detail view
per #381 — select-all checkbox, shift-click range, and Ctrl/Cmd+A
scoped to the open album with cross-view selection preserved.
cc
The album detail view (after drilling into a local album) had the
select-mode toggle button but was missing the rest of the multi-select
UX:
- Select-all checkbox in the track-list-header (only renders when
select mode is active). State is derived from albumTracks vs
selectedTrackIds so it accurately reflects partial / all / none
for the currently-open album.
- Shift-click range support: TrackRow's onToggleSelect now passes
the MouseEvent up. A new toggleAlbumDetailTrackSelect computes
the absolute index in albumTracks (so ranges work across discs)
and reuses applyShiftRange.
- Ctrl/Cmd+A while in album detail now scopes to the open album
and unions with any selection already built up in other views,
rather than replacing with library-wide tracks.
The toggle button itself (SquareCheckBig) was already wired — only
the missing pieces are added here. Cross-view selection composition
(tracks tab + album detail share selectedTrackIds) is preserved.
cc
Fixes#386 (duplicate-track selection collapse in playlist editing) plus
two related multi-select issues found while testing:
- Left-click on TrackRow checkbox now reliably toggles selection
(input made pointer-transparent, parent div owns the click).
- Selected row background bumped from 12% to 22% accent so the
visual cue is readable next to the saturated checkbox.
cc
Two related issues uncovered while testing the playlist duplicate-track
fix in multi-select mode:
1) Left-click on the checkbox did nothing while right-click marked it.
The input had onclick={(e) => e.preventDefault()} to suppress the
browser's native toggle, relying on the parent div's onclick to
dispatch onToggleSelect. Under webkit2gtk this combination
intermittently absorbed the left-click without bubbling cleanly.
Replace with style="pointer-events: none" on the input — clicks pass
straight through to the parent div, which owns the toggle. The input
is now purely visual, the div is the only event target. No more
bubble-vs-default-action conflict.
2) Selected row background was too subtle to read against the heavy
accent-coloured checkbox. Bump TrackRow's .track-row.selected from
12% to 22% (hovered variant 20% to 30%) and align
PlaylistDetailView's .track-row-wrapper.multi-selected wrapper
rule to 22% so the two sources of truth stay in sync.
Affects every view that uses TrackRow with selectable=true — Album,
Artist, FavQ, Playlist, etc — all benefit from the consistent
pointer-events pattern and more visible selected state.
cc
The explicit checkbox+label markup with onclick+preventDefault on the
input desynced the input's internal checked state from the rendered
checked attribute on click. Result: row background highlighted but
checkbox stayed visually unchecked.
Replace with the standard TrackRow.selectable/selected/onToggleSelect
pattern used by AlbumDetailView, FavQView, ArtistDetailView. Side
benefits:
- Shift/ctrl+click on row body now works for range/toggle multi-select,
matching UX in those other views
- Single source of truth for the checkbox visual (TrackRow internal),
no two-checkboxes risk
- Custom-order-mode checkbox preserved as-is - it uses onchange not
onclick and was never affected
cc
Bulk-edit selection used `${trackId}:${isLocal}` as the Set key. Two
duplicate tracks (same trackId+isLocal appearing twice in a playlist)
collapsed into a single entry, causing:
- clicking duplicate A selected duplicate B and vice versa
- background highlight and checkbox indicator desynced
- bulk delete removed both duplicates instead of the selected one
Fix: include the row index in getTrackKey so duplicates get distinct
selection keys. All 18 call sites updated to pass the index from the
local iteration context. The {#each} key was already index-aware
(visibleTrackRange.start + loopIdx + track.id) so Svelte's element
tracking is unaffected.
For Qobuz tracks the existing remove path uses playlistTrackId
(per-entry unique) so the correct entry is removed. Local-track
duplicates depend on backend semantics of v2_playlist_remove_local_track
keyed by localTrackId — that's a separate consideration outside this
UI fix.
Closes#386.
cc
Adds the 4-value titlebar mode selector (qbz | system | plasma | hidden)
replacing the three mutually-conflicting boolean toggles. Plasma mode
delivers KDE-native SSD via Xwayland on Wayland sessions; system mode
now also mounts a stripped strip below OS chrome when the user has
search-in-titlebar or any titlebar-nav item enabled.
cc
Bumped only for src-tauri's qconnect-discovery path. qbz-cast (chromecast/
DLNA) stays on 0.11 because mdns-sd 0.19 changed the address API
(HashSet<IpAddr> → HashSet<ScopedIp>) and adapting qbz-cast is real
work outside dependabot scope. Workspace now carries both versions
in the graph; both compile and operate independently.
cc
Plasma and system mode become structurally symmetric — both render OS
chrome on top (KWin SSD via Xwayland for plasma, GTK CSD / WM SSD for
system) plus an optional stripped QBZ strip below for search and nav.
Same content-driven rule applies to both: the strip mounts only if the
user has search-in-titlebar enabled OR any titlebar-nav item enabled.
With both off, the user sees just the bare OS chrome, matching the
previous system-mode behavior.
Effective rules table:
- showWindowControls / matchSystemChrome: user pref in qbz only
- searchBarLocation / titlebarNav: user pref in qbz/plasma/system,
forced/n-a only in hidden
i18n disabledHint strings updated to reflect the new rules across all
five locales.
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 32px guess from the spec left the search input touching the bottom
edge — no breathing room. 42px gives proper vertical centering matching
the qbz variant's visual rhythm. Tested visually on KDE+Wayland.
cc
The Task 9 SettingsView consumer reads settings.titlebar.* directly
(see lines 1030, 1037, 4666, 4678 of SettingsView.svelte). The original
Task 11 commit placed the block under settings.appearance.titlebar
which would not resolve. Moving the block one level up.
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
When v2_available_titlebar_modes fails, the dropdown's selected value
may not appear in its options. Include the user's current titlebarMode
in the fallback list to ensure the selected value is always present.
cc
Single Dropdown drives the titlebar mode. Orthogonal toggles
(window controls, match-chrome, search location, titlebar nav) are
shown disabled with explanatory tooltips when the active mode does
not apply them. Sandbox caveat is shown inline when Plasma mode is
selected inside Flatpak/Snap.
cc
The CSS at .titlebar / .titlebar.variant-full was already 44px before
this branch, and +page.svelte / Sidebar.svelte hardcode 44px in their
calc() reservations. The store was returning 40 — a latent lie waiting
for Task 10's layout consumers. Change qbz mode to return 44 so all
sources agree before they get wired together.
cc
The full variant matches current behavior. The stripped variant (used
in plasma mode) drops drag region, dblclick maximize, window controls,
and rounded chrome — KWin SSD owns those. Search and per-item nav
remain in the strip below the SSD.
cc
Search bar falls back to sidebar in system/hidden modes regardless of
user pref. Titlebar nav is N/A outside qbz/plasma. Both getters are
non-destructive: the user's persisted preference is preserved and
resumes when the mode allows it again.
cc
Two issues raised in code review on Task 6:
1. setMode now invokes the backend before mutating local state, so a
backend rejection leaves frontend state untouched and consistent.
2. Drop the unconditional v2_set_titlebar_mode invoke in initTitleBarStore.
The migration path already covers the legitimate divergence case;
firing it on every launch was overhead with no functional benefit.
cc
Replaces hideTitleBar/useSystemTitleBar booleans with a single mode
enum. First-run migration reads localStorage legacy keys, derives the
new mode deterministically, and pushes it to the backend. setMode
restarts the app only when the change crosses the decoration boundary.
cc
Reads the persisted titlebar mode at startup via WindowSettingsStore::new_readonly()
then composes the X11-forcing condition with the existing graphics force_x11
setting. Plasma mode now produces real KWin SSD on Wayland sessions without
requiring users to set the env var externally.
cc
Window builder now derives decorations from TitlebarMode::wants_decorations()
which returns true for System and Plasma. Transparency gate now requires
mode=Qbz so plasma/system/hidden modes do not build a transparent window
that would leak through KDE/system chrome. The v2 command registry is
updated to expose the new mode commands and drops the legacy boolean
setter.
cc
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
"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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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