750c59034b04cfb5010fffdc033e245b776f7c93
30
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bbce651d79 |
feat(qt): a Rip button on an open CD, and it asks where to put the album
The button lives on the ephemeral pane's header and appears only for a
PHYSICAL disc — an image is already a file, so offering to rip one would be
offering to copy something the user already has. Whether the session is a disc
is DERIVED from its rows rather than passed in: a flag somebody has to
remember to set is a flag that eventually is not.
The destination is always asked. The app knows several plausible music folders,
which is exactly why it must not pick one — a ripper that decides where your
music goes is a ripper you clean up after.
While a rip runs the button gives way to its progress ("3/7 · 45%") and a
spinner: one control, one state, rather than a button that looks pressable
during a job it cannot start twice. The bar is per-DISC and not per-track,
because a listener watching a 79-minute album wants to know how far the ALBUM
is, and a per-track bar that resets seven times reads like a stall.
Driven end to end on the owner's Fear Inoculum: seven tracks written into
"Tool - Fear Inoculum/", every one decoding clean and seekable, all tagged,
no `.part` left behind.
Four new strings in all eight locales.
cc
|
||
|
|
d9893b659a |
feat(qt): open the audio CD in the drive and play it
`Open > Open audio CD` reads the disc, builds an ephemeral session and lands
on its tab. Nothing touches library.db, which is the whole point of the
ephemeral path: a disc is content from outside the index that leaves with the
plastic.
It does not invent metadata. CD-DA carries no titles, and the disc measured
here reports its MCN as all zeros, so tracks are "Track N" and the album is
"Audio CD". A DiscID lookup is the obvious next step and is deliberately not
faked: a wrong title is worse than an honest number.
The feeder is the interesting part, and its liveness check took three designs
because the first two were wrong in opposite directions — both caught by
driving the app, neither by any test:
1. `current_track_id() != play_id`: `play_streaming` posts to the audio
thread and returns, so the id is not ours yet and the feeder retires
before reading a sector. Nothing played at all.
2. The same test with a wall-clock grace: a DEADLOCK. That field is stored
only once the audio thread starts playback, which needs the initial
buffer, which is what the feeder is filling. Waiting on it is waiting on
ourselves; the grace expired and the track stopped mid-play.
3. A feeder slot the audio thread plays no part in. A newer disc play claims
it and the older thread retires on its next block. The acknowledgement
path stays for "something else superseded us", but only AFTER the player
confirms our id, and with no timeout — a timeout is what kills a healthy
feeder.
A 16-bit WAV header, not `qbz_dsd`'s: that one is hardcoded to 24-bit, and
reusing it would either lie in the header or promote 16-bit samples to 24.
The shift is arithmetically lossless but it makes the DAC open at 24 bits for
a 16-bit source, which is precisely the silent conversion a bit-perfect path
must not contain. `play_streaming` also does not publish bit depth, so the CD
states 44 100/16 explicitly instead of inheriting the previous track's.
Driven end to end on the owner's disc: seven tracks with the right durations,
CD / 16-bit / 44.1 kHz badges, ALSA Direct opened at 44100 Hz in exclusive
mode, playback past 2:25 with no read errors, and two rapid skips retiring
their feeders in ~160 ms each.
Seven new strings in all eight locales.
cc
|
||
|
|
63a4f2daf1 |
feat(qt): the Local Library union, the settings store and the sweep
Three pieces that only mean anything together: rows in the shared cache,
a union that shows them, and settings that decide whether either
happens.
THE UNION. `get_albums_metadata_page` grows a second, INDEPENDENT
attachment. `plex_cache` stays; `remote_cache` is the shared mirror, and
they attach separately on purpose — a missing Plex cache must not cost
the user their Jellyfin rows. `attach_best_effort` is extracted because
the failure mode of a botched ATTACH is a silently local-only library,
and a failure stays non-fatal: refusing to list any albums because one
server is off turns "Jellyfin is unplugged" into "your library is
empty".
The union takes an ENABLED-SOURCES list, not just a path. The mirror
holds every remote source, so the master toggle has to be a filter
rather than a detach: turning Jellyfin off must hide Jellyfin's rows and
leave Subsonic's alone. That predicate is interpolated into SQL, so it
is VALIDATED rather than escaped — only [a-z] survives, an all-invalid
list yields `0` (show nothing, the safe direction), and a test feeds it
`x'; DROP TABLE local_tracks; --`.
The remote group key is `<source>:<album id>`, matching Plex's
`plex:<hash>`. That namespaces two servers using the same album id AND
lets a source claim an album card that arrives with no source word,
which is every card that round-trips through a QML string property —
`recognises` grew that arm and `claim` strips the prefix, because
carrying it inward is how Plex ended up with two album keys only one of
its queries understood (IC-2).
THE SETTINGS. One store for both servers, keyed by a `server` column,
mirroring the shared-cache decision. What each protocol needs differs
and `is_configured` is the one place that knows: Jellyfin needs its
issued token, Subsonic needs a password because it has no session and
re-derives `md5(password + salt)` per request.
Two identifiers are minted once and persisted, and both are
load-bearing. Jellyfin's DeviceId, because re-authenticating under the
same one revokes the previous token — a constant would make two installs
log each other out, a fresh one per launch drops the last run's token.
Subsonic's salt, because rolling it per request makes every cover URL
unique and the artwork cache keys on the URL.
THE SWEEP. In the frontend, where the runtime and the progress UI are,
writing through the cache handle the SOURCE owns so sweep and reads can
never point at different users. Jellyfin gets progress and a delta path
(45.8 s for 4924 rows); Subsonic needs neither (0.81 s for 6678).
`prune_stale` runs only after a COMPLETED full sweep — a delta never
prunes, because it deliberately does not see unchanged rows and would
read every one of them as deleted. `last_sync_at` is stamped only on
success for the same reason.
Verified against the running binary, twice:
source registry bound: local_tracks=617 plex=connected
ephemeral_store=yes remote=none configured
jellyfin: minted a device id for this install
subsonic: minted a salt for this install
...and on the second launch, zero re-mints and the same values on disk —
which is the property that keeps a Jellyfin session alive.
qbz-qt 428/428, qbz-app 224/224, qbz-library 100/100, qbz-source 88/88.
Release build clean, zero warnings.
cc
|
||
|
|
e6c058c2f2 |
feat(qt): immersive shader scenes — RHI items, scene menu, GL link pairing
Port the immersive GPU scenes: Plasma, Tunnel, Aurora, Ambient, Spectral Ribbon and Line Bed as QQuickRhiItems/ShaderEffects under a pulse-driven ShaderSceneLayer, plus the Qt-only Tunnel Flow (Tauri Canvas2D port) fed by the Viz16 stream. Scene rows live in the header view menu (no Off row); g cycles the ring. A fresh immersive open defaults to Split > Lyrics (owner ruling). OpenGL link fix (owner smoke: Tunnel black with "uniform _22 declared as type buf and type buf"): every ShaderEffect fragment with its own uniform layout now ships a paired vertex stage declaring the identical block — the shared spectrum.vert only ever linked under Vulkan. RHI hardening: render pass descriptors on the ping-pong targets (the Tunnel Flow segfault / Plasma dead window), mirrorVertically tied to the backend (!== OpenGL), ribbon sub-uploads carry setSourceSize. km |
||
|
|
21e941bdf6 |
feat(qt): renderer tier, GPU selection and a real shader build
Closes PARITY-DEBT #83 and #104, and unblocks the immersive shader scenes. THE TIER. Slint resolves one bool, use_gpu_renderer, and hands it to three features: the shader scenes, the dynamic background and half of reduce-motion. This port had none of it, which is why the scenes could not be offered — off the GPU tier they render black and nothing could tell. QbzShell now carries rendererApi, gpuTier, shaderScenesAvailable and appBackgroundAvailable, all written from one latch. Only QML can source it: QRhi picks the backend after QGuiApplication, so the pre-init code knows only what it asked for. Per-item GraphicsInfo.api stays where it is. "Can this item draw?" and "should this feature be offered?" are different questions, and routing the first through Rust would make every cover wait on a round trip. SHADERS ARE BUILT NOW. build.rs bakes every .frag/.vert to .qsb with ten variants and the batching rewrite. They used to be baked by hand and committed carrying no GLES variants at all, so any GLES context failed them with "No GLSL shader code found" — which is exactly how the NPB Large visualiser went blank while this branch was being tested. Adding a scene is now dropping a .frag in the folder. GPU SELECTION enumerates real devices through Vulkan and lists them by model, never by class: a machine with no discrete GPU can no longer be told to use one. Legacy class keys still resolve, but only against hardware that exists. Picking a non-default device implies the Vulkan backend, which the row's own description states in all eight locales — measured as the only route that keeps a working context: DRI_PRIME lands on llvmpipe and the NVIDIA PRIME pair lands on OpenGL ES. The renderer row gains the auto-revert sentinel and the frame-liveness watchdog it shipped without. The sentinel arms only for a choice that truly forces a backend, and liveness means frames rendered over a window, not merely a window that opened. Also fixes the kiosk toggle overwriting the tier half of reduce-motion. Verified by 312 tests, both QML audits, the offscreen smoke, and a probe against the real hybrid GPU: both devices enumerate by name and the stored preference applies. cc |
||
|
|
8ba9483462 |
feat(qt): HiFi Wizard — guided DAC setup ported from Slint
Six-step modal (Welcome / Check / DACs / Configure / Test / Done) opened from Settings > Audio. It probes the audio stack and renders per-distro remediations, enumerates DACs with a manual node.name escape hatch, generates the PipeWire / WirePlumber snippets to copy, and plays four tracks spanning 16/44.1 to 24/192 with a live requested-vs-negotiated read-back. Read-only: it never writes a system file or runs a command. The logic was unreachable from Qt — every public function in qbz-dac-wizard takes a &AppWindow and writes straight into Slint globals. It is lifted into a new frontend-agnostic crate, qbz-dac-wizard-core, which returns plain data. qbz-dac-wizard itself is left untouched. Rust owns everything it computes plus `open`, so the reset and the probe are one act; QML owns the step navigation and the four progress checkboxes, which is how the reference splits them too. Two deliberate divergences from the reference: the row sits at the end of OUTPUT rather than BIT-PERFECT, and it is offered on every backend instead of PipeWire only — someone on ALSA direct is exactly who needs the walkthrough. The green glyphs use the accent tint, not success: success has no qrc bake directory, so a degraded tint set would render nothing at all. Every success-toned text keeps theme.success (#3fae6a verbatim). Verified by 309 tests, both QML audits and the offscreen smoke, plus a forced-route probe that mounts Settings and walks the wizard's steps — the standard smoke only mounts Home, so it is no evidence for a modal. The modal was also driven and screenshotted over VNC. cc |
||
|
|
5e1eb46a71 |
feat(qt): window chrome parity — macOS tray, native chrome, appearance wiring
Three Slint-parity pieces that share the chrome surface: - macOS menu-bar tray (NSStatusItem, tray_macos.rs) ported from the reference — the K2 'macOS is a new implementation' ruling read the reference backwards (it avoids muda entirely), and its absence made close-to-tray quit the app on the Mac. Plus the custom-chrome window mode (macos_chrome.rs): Qt 6.9 ExpandedClientAreaHint / NoTitleBarBackgroundHint instead of fighting QCocoaWindow's style-mask recompute, traffic-light centring, and the SafeArea inset in HeaderBar. - Appearance rows that rendered, persisted and drove nothing now push live: hide/show title bar, window-controls cluster side, track in the window title, invert swipe navigation, auto-theme source selection (wallpaper/image/system) with regeneration on source change. - use_system_title_bar now defaults per-OS (custom chrome on macOS), matching the reference — the hard-coded 'true' also leaked into the Slint build through the shared ui_prefs.json. cc |
||
|
|
024ed615e8 |
fix(qt): MainThreadMarker::new() needs objc2-foundation's NSThread feature
Second one the Mac build caught and Linux could not: the type resolved, but `new()` did not exist — it is gated on `NSThread` (objc2-foundation-0.2.2/src/thread.rs:210-213), leaving only the unsafe `new_unchecked`. That is precisely the wrong half: the point of the call is to ASK whether we are on the AppKit main thread and no-op when we are not. cc |
||
|
|
1b18adcde0 |
fix(qt): MainThreadMarker comes from objc2-foundation in this generation
The Mac build caught it on the first try, which is exactly what it was for: `use objc2::MainThreadMarker` does not resolve — in the objc2 0.5 / objc2-foundation 0.2 generation the type is exported by FOUNDATION (objc2-foundation-0.2.2/src/lib.rs:152, ungated); it only moved to the objc2 root later. The reference imports it from the same place (crates/qbz/src/tray/macos.rs:38), which is where this should have been read from in the first place rather than assumed from a newer API. The direct `objc2` dependency goes with it: nothing here names a type from that crate. cc |
||
|
|
dc604bdf62 |
feat(qt): wire the macOS Hide-Dock setting — Spotify behaviour on close
The row rendered, persisted and drove nothing, which is the exact defect class the miniplayer/tray contract existed to end. The owner named the behaviour they want: close puts the app in the menu bar AND drops the Dock icon; minimize still goes to the Dock like any app. So only the CLOSE path touches the policy. The wiring point is `QbzTray.setWindowShown`, which QML calls from exactly two places — `hideToTray` and `showFromTray` — so this needs no third visibility owner and cannot drift from the real window state. Hiding drops the icon only when the setting is on; SHOWING always restores it, with no setting check. A user who turns the option off while the app sits in the menu bar must still get their icon back, and restoring an icon that is already there costs nothing. NOT COMPILED AND NOT TESTED. This tree builds on Linux, the arm is behind cfg(target_os = "macos"), and a cross-check dies in the C build scripts for want of an Apple SDK. So the API was verified by READING objc2-app-kit 0.2.2 instead — which immediately earned its keep: `NSApplicationActivationPolicy` lives in NSRunningApplication.rs, not NSApplication.rs, and the NSApplication feature does not pull it. The three features here match the reference's own proven set. First real check is a build on the Mac mini. cc |
||
|
|
721841a4b8 |
fix(qt): Qt.quit() was vetoed by our own windows, so Quit never quit
The owner reported it three times and their second wording was the whole
diagnosis: "Quit QBZ debe cerrar la aplicación, NO SOLO LA VENTANA."
ROOT CAUSE. `Qt.quit()` in Qt 6 is a REQUEST, not an order. It posts
QEvent::Quit, and QGuiApplication answers it by calling close() on every
top-level window that has a platform handle — hidden ones included. If any
window refuses its close event, the quit is silently cancelled. Two of ours
refuse:
1. Main.qml's closeOrHide refuses whenever trayLive && closeToTray, and
hides the window on the way out. That IS "cierra la ventana, no la app".
2. MiniWindow's onClosing refuses unconditionally, because for the mini a
close means "leave the mini" — so once the mini had ever been opened,
quit was blocked regardless of any setting.
It also explains "Close to tray does nothing": with the setting ON, the X
hid (correct) and Quit hid too (the bug), so the two were indistinguishable
and the toggle looked inert. Nothing was ever wrong with that plumbing.
FIX. Both quit sites call Qt.exit(0), which exits the event loops directly —
no QEvent::Quit, no window to veto it, and no need to touch the miniplayer's
onClosing, whose refusal is correct for what it means. A one-shot 5 s
watchdog thread, armed only at the three quit sites, flushes the log and
_exit(0)s if anything downstream ever wedges; it cannot fire in a live
session. main() now names each teardown step, so a future hang says which
statement it hung on instead of going quiet.
closeOrHide reports its decision through the bridge — tray_live,
close_to_tray and the arm taken — because a report of "the tray does
nothing" was not previously separable from "the signal arrived and QML
ignored it".
MEASURED, with waitpid on a child rather than /proc (a zombie reads as alive
and that produced a false diagnosis earlier in this session). Old binary:
alive ≥25 s with the window visible, hidden, and with the mini open. Fixed:
exit 0 in 0.35-0.68 s across eleven driven scenarios — the tray Quit in all
three window states, the drawn X and the app-menu Close with the setting
both ON and OFF, the mini's own close button, and the MPRIS Quit method.
One arm remains static-only: a real WM close event with close-to-tray OFF.
VNC delivers no such event, so the owner's first titlebar X is its first
real execution.
cc
|
||
|
|
ce7f29ff9c |
chore(qt): ship the binary as qbz, and log the first MPRIS metadata push
The artifact is now `target/<profile>/qbz` (owner, 2026-08-04). The package stays `qbz-qt`, so `cargo build -p qbz-qt` is unchanged. ⚠ `crates/qbz` — the Slint binary — declares the SAME bin name and shares this workspace's target dir, so both write `target/<profile>/qbz` and the last build wins. Survivable only because the Slint binary is never built in this worktree (the qbz-ui 20-30 GB wall); if it ever is, a smoke against "the wrong qbz" is silent. Written into Cargo.toml beside the name. The MPRIS line exists because "Plasma sees nothing" has two very different causes — the player never registered, or it registered and no metadata was ever pushed — and the log could not tell them apart. It settled that question the same day: the owner's log shows `[mpris] metadata pushed: Holy Diver — Dio (350s)`, so the port's half works and what remains is desktop-side. cc |
||
|
|
5dd450fb9f |
feat(qt): MPRIS — the desktop can finally see the player
Owner ruling K3 kept MPRIS out of the miniplayer/tray contract, and its default shipped. The owner reversed it after smoking the tray: QBZ did not appear in KDE Plasma's now-playing widget. Plasma reads MPRIS, so without this the desktop cannot see the player at all. src/media_controls_qt.rs (new) — a plain module over the SHARED qbz-media-controls crate, following the qbzd precedent rather than the Slint one because qbz-qt is also not a Slint app. Outbound: metadata on the track edge, playback state on the play/pause edge, both carrying position. Inbound: play, pause, toggle, next, previous, stop, seek and volume, plus Raise — which calls tray_qt::present() unchanged, the mini-aware verb the tray contract shaped for exactly this caller. src/artwork_qt.rs — cached_raw_path, the remote arm of cached_path without its file_url step, so the art URI is built for MPRIS rather than for QML. src/playback_qt.rs — four sibling pushes beside the tray's: both arms of refresh_now_playing, and BOTH is_playing != was_playing blocks. One of two is the silent half-port, so the grep is the instruction. Verified on the session bus, not by reading: qbz-qt owns org.mpris.MediaPlayer2.com.blitzfc.qbz, Identity "QBZ", DesktopEntry com.blitzfc.qbz — that is what gives Plasma the icon — with CanControl, CanPlay, CanPause, CanGoNext, CanGoPrevious, CanSeek, CanRaise and CanQuit all true and PlaybackStatus tracking. Metadata is empty until something plays, and that half is NOT verified: it needs audio. cc |
||
|
|
56d4387172 |
feat(qt): system tray via ksni
Block B5 of the miniplayer/tray contract. The tray settings rows have rendered and persisted since the Settings port and driven nothing; this is what they drive. src/tray_linux.rs (new) — the ksni StatusNotifierItem: the twelve icon pixmaps, the variant ladder, the tooltip, the six-item menu, left/middle click and scroll, and the qbz-tray-updater thread every mutation crosses. Note the filename inversion it reproduces: tray-light-* holds the BLACK glyph, so the "mono-light" setting loads tray-dark-*.png. src/tray_qt.rs (new) — the portable half: init's three gates, the window verbs, the 400 ms toggle debounce, the transport dispatchers and in_gamescope(). toggle_window is mini-aware — while the mini is open a tray click raises the MINI, because a mini-blind toggle would put the main window beside a live mini on the first click. src/tray_bridge.rs (new) — QbzTray: trayLive, closeToTray, and the four signals the ksni thread reaches QML through. Rust cannot touch a QQuickWindow, so every verb is a signal. icons/ — the twelve PNGs, copied rather than include_bytes!'d across crates, so this crate does not depend on a file inside the Slint binary crate. src/playback_qt.rs — the three live-update pushes: set_track and clear_track in both arms of refresh_now_playing (the publisher with 23 call sites, so a QConnect or cast track change updates the tooltip too), and set_playing in BOTH is_playing != was_playing blocks, each beside the existing discord_push. src/settings_qt.rs — tray() widened to pub(crate) for the session-entry read; the tray_close_to_tray snapshot default corrected from false to true, which was cosmetic while nothing consumed it and becomes behavioural now; and the tray-icon-theme select arm re-themes the running icon with no restart. Cargo.toml + Cargo.lock — ksni 0.3 with default-features off. Never the tokio feature: the blocking API panics inside tokio and the panic is swallowed, which is why the two dedicated threads exist. src/main.rs, build.rs, qml/Main.qml — the registrations, and tray_qt::init in on_session_entered. That call site is also deliberately the process's first settings_qt::tray(), because the store binds per user and a read from the bridge's construction seed would latch an empty one at the login screen. The Connections block that answers the four signals lands with B6, together with the window verbs it calls. Until then the tray renders, its tooltip and menu are live, and its clicks reach Rust and stop there. cc |
||
|
|
edbc037b0f |
feat(qt): Rust share seam, and fix the dead label share link
The port had no clipboard seam at all. share_qt.rs ports the URL builders and the clipboard from crates/qbz/src/share.rs:1-70. The long-lived OnceLock<Mutex<Option<Clipboard>>> is copied verbatim with its reason: arboard ties the offer's lifetime to the LAST live Clipboard object, so a create-per-copy port passes on KDE, which runs a clipboard manager that accepts the handoff, and silently loses the text everywhere else (#514). arboard carries the wayland-data-control feature at the same pin the reference uses; without it arboard is X11-only and every copy fails on a Wayland-only session. The label header's Share was not merely silent - it copied a link that does not resolve. open.qobuz.com has no /label/ route. The host is per-ENTITY, not per-app: track and album on open.qobuz.com, artist, playlist and label on play.qobuz.com. That asymmetry is why the five builders are ported as a set and why no call site formats its own URL any more: this one did, and got it wrong. QbzHome::label_share now copies play.qobuz.com/label/{id} and raises the "Link copied" toast, 1:1 with main.rs:13164-13178, and the QML Loader/TextEdit clipboard hack it replaced is deleted - its own comment claimed there was no toast seam, which stopped being true when toast_qt.rs landed. Only the URL builders and the clipboard are ported. share.rs:72-184 is the Song.link/Odesli resolution, which applies to tracks and albums; artists and labels are link-only, so porting it now would be dead async HTTP. LabelView also adopts QbzLoadMore here - its two changes share the file. cc |
||
|
|
c8559a68b3 |
feat(qt): port Qobuz Connect — service, flyout, routing and touchpoints
Full-parity port of the Slint QConnect integration per the contract at qbz-nix-docs/qt-frontend/2026-08-01-qconnect-port/00-CONTRACT.md (three adversarial review rounds on the spec, §12 trap sign-off PASS on both halves, RFB-verified incl. a real cloud connect, live session owner-tested). The shared crates are consumed verbatim — zero backend changes. - adapter quartet: qconnect_transport_qt (credentials, identity on the shared qconnect_settings.db), qconnect_engine_qt (start_track_stream fallback chain + feeder helpers + CMAF cursor sync), qconnect_event_sink_qt, qconnect_qt (the facade: connect/disconnect recipe, controller routing, echo guards, offline parking B12, startup auto-connect) - QbzQConnect bridge singleton + on_session_entered wiring; one shared flyout for both bars (tooltip on the large bar only), Developer > Connect diagnostics modal, device-row glyphs, settings rows live - poll loop: peer-reflection branch before cast, ~2 s renderer reports, sync-on-add on every Qobuz-castable add path - routing: cast → QConnect → local (mute QConnect-first), play arms after the set_queue funnel, reorder cloud arm with no local fallback, volume/mute/seek remote-first - touchpoints: lyrics remote anchor, scrobble guarded / Discord unguarded, cast suspend/resume, volume-lock lifted when remote km |
||
|
|
a16cf56c86 |
feat(qt): playlist manager, folders, importer and the Library parity round
Ports the whole playlist-organisation domain, which had no counterpart in this frontend, plus the Library work the owner asked not to defer. Playlist Manager. New controller (playlist_manager_qt, _rows, _ops) over the already-ported folders_qt repository, which until now had zero consumers. Grid, list and folder-tree modes; sort, filter and search; folder cards and chips; drag reorder and up/down arrows. Reached from the sidebar's "..." menu, whose New folder / Manage playlists / Import rows had been rendering dimmed and inert behind a comment saying the seam did not exist. Folders. QbzFolderEdit plus the create/edit modals and PmFolderIcon: preset or custom-image icons, colour, hidden flag, delete. Deleting a folder nulls its LOCAL members' folder_id explicitly, because the app keeps the foreign_keys pragma off, so the schema's ON DELETE SET NULL never fires for them. QbzPlaylistEdit. One shared rename/description/delete editor replacing the inline popup in PlaylistView. Three corrections the old path had wrong: the description is carried through a rename instead of being written as NULL, a FOLLOWED playlist routes to unsubscribe (playlist/delete returns 200 and silently no-ops on a playlist you do not own), and the post-delete back navigation is conditional on actually being on that playlist's page, so deleting from the manager no longer navigates you off it. Importer. Spotify / Apple Music / Tidal / Deezer, over the shared qbz-playlist-import crate. Picker local mode. The reference's four-case matrix — target local or Qobuz, payload local refs or Qobuz ids. Local playlists list unconditionally and Qobuz ones only when online. Local refs onto a Qobuz playlist write the library.db sidecar at next_playlist_sidecar_position(pid, qobuz_count); the 0-based enumerate the reference used before collided slots and lost rows. The carried payload can no longer be parsed as a catalog id when it is a library row id. Unblocks the local bulk add-to-playlist. Library. A-Z jump strip, Albums list mode, Artists side panel, per-tab persistence and multi-select. Preferences land in the same <data-dir>/qbz/favorites_ui.json the shipping build writes, so switching between the two frontends keeps them. LibraryView is split into views/library/ on the way past 1800 lines. Local library. Group-by now persists; playback queues the order on screen rather than the SQL page order; switching album-identity mode invalidates the Artists tab; artist matching folds diacritics and compares normalized credit parts, so "Air" stops listing "Airbourne" and "Beyonce" stops being two artists. Offline. activate_offline never persisted a last-user marker, so every per-user path resolved to None and all local reads came back empty; a session that started offline saw no local playlists at all. The sidebar's load-once latch was function-local and therefore unreachable from the logout reset, so a second login never rebuilt the tree. Both are session-scoped now, and the sidebar Refresh row runs the local-safe verb instead of being dimmed with no way to recover. Also fixes, found while verifying rather than reported: artwork derivatives were written straight to their final path, so a QML Image resolving one mid-encode opened a truncated png and Qt cached that failure against the URL for the rest of the session; Library's track group separators referenced a Component declared in another delegate's scope and could never render; the genre filter was drawn on Tracks and Albums but applied only on All; an All-feed row called a player method that does not exist; and the sidebar's drop-hot highlight used Slint's #RRGGBBAA literals in Qt's #AARRGGBB slot. 17 new msgids across all 8 locales. cc |
||
|
|
b6cf06ff2a |
feat(source): wire My QBZ through the seam, and land the owner's container rules
Stage 2. The measured win: opening a 34-item collection now costs **2** library.db opens instead of 23-34 — the local source owns a pooled handle and hands out no guard, only `with(|db| ...)`, so a `!Send` `&LibraryDatabase` cannot cross an await either. Bug 1 from the seam design is dead by construction, not by care. The owner's rules, decided today, now live in one place instead of eight: - **Collection** takes albums, singles and EPs from ANY source — qobuz, folders, Plex, and whatever is added next. **Mixtape** takes albums, playlists and tracks. This was a single boolean (`restrict_to_mixtape`) that could express neither release types nor "collections accept local". - An unsupported combination must never be VISIBLE: the picker stops offering the impossible action, and the error stays a log-only safety net. Owner: "el bloqueo no debería ser visible nunca, más bien esto hay que ajustarlo en la UI". - **Ephemeral honours its name** — playable, queueable, and offerable to nothing else. - A row's source comes from the ROW. Eight QML sites built the add-to-mixtape payload with `"source": "qobuz"` written as a literal, so a Plex or local track was stored as Qobuz and afterwards could not play at all: the app asked the Qobuz API for an id that never existed there. `QobuzSource` reads the client through a lens on every call instead of holding a clone. That is not style: `qbz-core` REPLACES its client (core.rs:346 and :384, both on paths that run before `set_session`), so a cached clone goes stale and fails silently — the hazard the crate's own doc comment described. The lens is installed in `on_boot`, first of everything, and finding out why is the lesson of this commit. The gate was green — build, 142 tests, both QML audits, a clean offscreen smoke — and the feature was visibly broken: every Qobuz row showed "No results found", an empty Quality cell and a wrong "SINGLE" type, while the Plex rows resolved fine. The registry is a OnceLock, so whoever touches it first BUILDS it, and one built without the lens leaves Qobuz permanently detached. Only driving the real app surfaced it; the crate's error string named the cause exactly, which is the one thing that made it a ten-minute fix instead of an afternoon. cc |
||
|
|
acf23abc9b |
feat(qt): port the My QBZ domain — mixtapes, collections and the discography builder
The Slint reference is 3,885 lines of .slint across ten files plus 5,514 lines
of Rust glue over the shared qbz-mixtape crate. None of it existed in the Qt
port; the nav flyout carried a "GAP:" comment and two disabled entries.
Rust (flat in src/, per QTBUG-93443): three bridges rather than one God-object
— QbzMyQbz (grids + detail + edit + cover + mix), QbzMyQbzAdd (the app-wide
picker, kept apart so TrackRow, PlayerBar and the Local Library bulk bars
cannot couple to detail state) and QbzDisco (artist-scoped, reached from the
artist page, never coexisting with the MyQBZ views). Twelve controllers behind
them, ported function by function from myqbz{,_detail,_play,_edit,_cover,_mix,
_add,_prefs,_view_prefs,_builder}.rs. src/toast_qt.rs finally gives the port a
toast host.
QML in qml/views/myqbz/ plus four global overlays. Reused rather than forked:
TrackRow, TrackListHeader, QualityBadgeFull, QbzContextMenu, CardMenu,
QbzCheckbox, QbzSkeleton, QbzScrollBar, RoundedImage. LocalSegToggle and
LocalMultiSelectBar are PROMOTED to controls/QbzSegToggle and
controls/QbzMultiSelectBar (they were ports of shared Slint primitives that
MyQBZ needs too) — a move plus an import fix, with the Local metrics kept as
the defaults so the four Local Library toggles are byte-unchanged and the MyQBZ
call sites pass ViewToggle's own numbers.
Every list is windowed at write time: GridView/ListView with reuseItems and a
cacheBuffer, and expanded mode is an outer recycled ListView whose delegate is
a Column + Repeater, because a nested ListView inside a recycled delegate
cannot size itself. Single-row updates patch in place; replacing the array
resets contentY and throws the user to the top.
Shared controls gained the arms the new views need, with the numbers taken from
the .slint and not guessed: SettingsButton (btnHeight / minWidth /
trailingIconName), QbzEmptyState's four metrics, QbzSelect's `sm` variant
(30px, r6, no outline, 12px label, 14px chevron, 40px narrower —
QbzSelect.slint:74-148), QbzContextMenu.openBelowLeft, and QbzLineEdit gains a
distinct `accepted` signal fired on ENTER ONLY: a modal that submits on
`committed` submits TWICE, because closing it removes focus and fires
`committed` again — a rename would have run twice and a create made two
collections. clearSearch() no longer assigns root.text either; that JS write
destroyed the call site's binding permanently, so Rust could never re-seed the
field after one click on the X.
SourceIcon gained the plain "qobuz" arm it was missing, which had every Qobuz
row in a collection drawing itself as a local file.
Two icons (turntable, image) are baked from the Slint masters into the seven
full tint dirs and registered in MASTERS; three more (circle-check-big,
circle-alert, triangle-alert) back the toast. A missing tint dir or glyph
renders nothing and logs nothing, so this class never surfaces in a build.
cc
|
||
|
|
87b18fa7f7 |
feat(qt): wire local-network casting end to end
The two cast buttons in the now-playing bars rendered and did nothing: no service, no bridge, no picker. This ports crates/qbz/src/cast_service.rs onto the shared qbz-cast crate (discovery, both device families and the media server are the crate's, not reimplemented) and hangs a QbzCast singleton off it. Discovery is armed only by openPicker() and stopped by closePicker(), so a user who never opens the picker sends no mDNS/SSDP traffic. Connecting stops the local backend through its public seam and re-casts the current track at the streaming preference clamped by the per-renderer cap. Playback is routed at four seams in playback_qt (new track, toggle, seek, volume) and the local poll yields while casting — without that the two polls fight over the bar every second. Logout and exit tear the connection down, or the renderer keeps playing after the app is gone. Deliberate gaps, each named in the module header: no offline-cache tier, no QConnect suspend/restore, no Plex casting (the Slint carries the same TODO), and lyrics do not follow a remote position yet. cc |
||
|
|
d76f694781 |
feat(qbz-qt): close the two declared debts on the album and artist pages
The Last.fm similar-albums row exists now. The recommendation building was already frontend-agnostic in qbz-external-reco, so this only adapts QbzCore to its catalog trait and publishes the result; the row seeds on the album's artist and excludes what the two Qobuz rows already show, including ids the pre-resolution dedup misses. Resolved rows cache per album for thirty days in the same store the Discover tab uses, so re-opening an album makes no Last.fm or Qobuz calls, and only a non-empty result is cached — a transient failure must not hide the row until restart. With Last.fm not connected it resolves to nothing and makes no network call, so the rail simply never appears. Relationship rows navigate. They carry a name rather than a catalog id, so the click resolves through MusicBrainz first and only a CONFIRMED match opens an artist — landing the user on a same-name musician is worse than a row that does nothing. The role travels with the name so a match in another role is not mistaken for this one. cc |
||
|
|
31b1484c01 |
feat(qbz-qt): Local Library complete — five tabs, routed album page, Plex
The view had roughly a folder tree and an album grid inside one 1,847-line file. Slint's is 2,671 lines across five tabs, and the local album detail is a routed page of its own. This closes that gap and splits the result: the view becomes a composition root over qml/views/local/ modules, and local_library_qt.rs becomes a narrow facade over local_* modules by concern. Plex is wired for real. The port hardcoded the cache path to None, so the union was local-only and Plex thumbnails were skipped. The settings store binds per user at session activation, the album and track union includes Plex, thumbnails resolve through the artwork path, rows carry their source badge, and the manual re-sync is on the toolbar behind the availability gate — as in Slint. Tabs: Albums with its quality/format/source filter popup, Tracks paginated, Folders in both flat and two-pane tree modes, Artists as an A-Z master/detail. Every list is windowed from the start, artwork id-keyed and evicted, because 16k tracks is exactly where the Slint build freezes. The tree grows a real select mode with its bulk bar, the ad-hoc "ephemeral folder" pane plays music that was never indexed, and the album page gets its version picker — a local album with two physical copies now switches between them without a database round-trip. Two agents built the QML and the Rust halves in parallel and the QML called thirty QbzLocal members that did not exist. QML resolves lazily, so each would have been a control that renders and does nothing; a static cross-reference caught them before a build ever ran, and they are implemented here. Deliberately logged seams, not half-paths: add-to-playlist, add-to-mixtape and the tag editor have no picker or modal in this port yet, and local favourites are not wired, so those actions log rather than route a local id into the Qobuz API. Plex playback downloads the part instead of range-streaming it, which is Slint's own documented fallback. cc |
||
|
|
ea644f5e05 |
feat(qbz-qt): phase 19 — full Settings subnav, Appearance + Integrations, live theming
Theme system as data: qbz_theme registry (36 themes + auto/custom) resolved from the shared ui_prefs slug into a themeJson document; QbzTheme rebound to it so the whole app repaints live (OLED->Light probe-verified). Full subnav (8 sections + Share logs footer); Appearance 1:1 (theme dropdown with dark/light filter, ambient/titlebar rows in their real home, typography, library visuals, window chrome, tray via tray_settings.db, renderer); Integrations 1:1 (MusicBrainz, Last.fm 2-step browser connect + authed state, ListenBrainz token validate, Discord toggle -> live DiscordRpc.set_enabled). Offline/Local Library/Blacklist/Developer render honest placeholders. km |
||
|
|
9f86a40f52 |
feat(qbz-qt): phase 14 — app-wide ambient background (modo Cider)
Port of the spec's palette derivation (16x16 downscale, coverage- weighted 24-bin hue histogram, genuine secondary cluster or deeper same-hue, +180 accent, B/W defaults) driven by the now-playing artwork on track change. QPainter AmbientField with the WGSL scene's exact four blob orbits, dim scrim, 30fps cap paused when inactive; chrome surfaces translucent per the AppShell alpha tokens (content frame surface-main @ 0.22 frosted). Same shared app_background pref (owner has it 'ambient'), app-menu toggle applying live, QBZ_BG_* env knobs. 4 new unit tests (14/14 total). km |
||
|
|
d57cec974f |
feat(qbz-qt): phase 10 — Settings > Audio and Playback 1:1
settings_qt.rs controller over the shared stores (AudioSettingsState, PlaybackPreferencesState, ui_prefs.json, qconnect_settings.db) with the upstream cascades (dac-passthrough->skip-sink-switch, streaming-only-> gapless, backend switch resets routing) and live apply through public player APIs (reload_settings/reinit_device/clear_audio_cache). ALSA device regrouping (Defaults/Bit-perfect/Plugin/Other + BP badges), searchable QbzSelect, QbzToggle/QbzSlider/SettingRow replicas, exact row order and gating. Persistence probe verified (gapless + streaming-quality round-trip, original values restored). km |
||
|
|
b10ff98620 |
feat(qbz-qt): phase 9 — Queue homologation + Lyrics side panel
Queue panel 1:1: History tab (click replays per queue.rs semantics), #442 'Next in queue'/'Next up' section markers from manual_next_count, 40-row pagination + live search + page clamp, row actions (play, remove, remove-all-after, heart), press-drag reorder via move_track, clear queue, plural-aware footer count, exact empty states. New Lyrics panel: qbz-lyrics service + per-user cache wired, Qobuz-first chain with LRCLIB fallback, synced line view with line-level highlight + centered auto-follow + click-to-seek, exact status bodies. Both panels stack Feishin-style in the right column; NPB lyrics button wired. km |
||
|
|
1b81b289d1 |
feat(qbz-qt): phase 5 — Library view 1:1 with All grid/list and perf marks
Six live tabs (All/Tracks/Albums/Artists/Playlists/Labels) with real count badges, paged favorites/playlists/purchases loaders, library_all merge semantics, heart routing (Qobuz API vs local store — LocalFavoritesService now initialized at session entry), sort popup with direction carets, grid/list toggle with natively windowed GridView/ListView delegates, id-keyed artwork via signal with hand- rolled eviction. Perf marks on the owner's account: 344 items, fetch wall 2.7-3.4s (97% network, sequential), map+merge ~11ms, JSON serialize 10ms, QML parse 7ms (~20us/item => ~140ms at the 2200-item reference scale), RSS +29MB after publish. km |
||
|
|
95df643a25 |
feat(qbz-qt): phase 3 — Discover > Home with live Qobuz data and artwork
Pure mapping port of home.rs/foryou.rs (quality tiers, award ribbons, slim grids) into Slint-free rows; discover index + favorites + release-watch + top artists fetched concurrently (12 sections, ~170 cards live-verified). Artwork via the shared qbz-cache ImageCacheService (semaphore-16 downloads, file:// rows, native QML decode). HomeView.qml: tab pill, five rail kinds, shimmer skeleton, error+Retry, offline placeholder — exact msgids throughout. Sections travel as one JSON document (cxx-qt-lib cannot nest QVariant containers — documented). km |
||
|
|
df7eb3588e |
feat(qbz-qt): phase 1 — login screen with full offline guardrails
QbzBridge QML singleton (12 properties, 8 invokables) driven by tokio tasks via CxxQtThread. Port of auth.rs (browser OAuth loopback listener with qbzd-style nonce, 180s timeout, cancel via JoinHandle abort, scoped offline-gate lift for cold bundle init, D1 token-keep on network failure / clear on explicit rejection, D4 subscription grace marks). OfflineModeEngine + ConnectivityActor started at boot with live watch forwarding (connectivity/captive portal/recovery banner/has-previous- session). Boot: splash -> silent token restore -> shell | login. Start-offline enters the guest profile. LoginScreen.qml is a faithful port with exact msgids routed through qbz-i18n. 5 unit tests green. km |
||
|
|
469f83781d |
feat(qbz-qt): phase 0 — crate skeleton + cxx-qt toolchain gate
New qbz-qt bin crate (cxx-qt 0.7 + QML) as the only default workspace member in this worktree, so plain cargo build produces just the Qt binary. Gate verified against system Qt 6.11.1 (codegen, moc, qmltyperegistrar, qrc load, offscreen run all green). km |