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
This commit is contained in:
vicrodh committed 2026-07-29 21:23:45 -06:00
1 parent d4667f5ea9
commit acf23abc9b
107 files changed
+14608 -226

No files matched your search

+2
View File
@@ -6633,6 +6633,7 @@ dependencies = [
"qbz-library",
"qbz-log",
"qbz-lyrics",
"qbz-mixtape",
"qbz-models",
"qbz-offline-cache",
"qbz-plex",
@@ -6641,6 +6642,7 @@ dependencies = [
"qbz-theme",
"rand 0.10.1",
"reqwest",
"rfd",
"rusqlite",
"serde",
"serde_json",
+17
View File
@@ -32,6 +32,11 @@ qbz-models = { path = "../qbz-models" }
qbz-cache = { path = "../qbz-cache" }
qbz-offline-cache = { path = "../qbz-offline-cache" }
qbz-library = { path = "../qbz-library" }
# MyQBZ: Mixtapes & Collections backend (schema + migrations, repo, shuffle,
# enqueue). Headless and frontend-agnostic (ADR-006) — the same crate the
# Slint app uses (crates/qbz/Cargo.toml:96), so the two frontends share
# library.db's mixtape tables rather than each growing their own.
qbz-mixtape = { path = "../qbz-mixtape" }
# Cast: Chromecast + DLNA discovery, the two device families and the local
# media server (src/cast_qt.rs drives it — nothing is reimplemented).
qbz-cast = { path = "../qbz-cast" }
@@ -50,5 +55,17 @@ async-trait = "0.1"
rusqlite = { version = "0.31", features = ["bundled"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
# Native file picker, used by exactly two call sites — the MyQBZ collection
# cover upload (src/myqbz_cover_qt.rs) and the My-QBZ branding icon picker
# (src/myqbz_prefs_qt.rs).
#
# The feature set is NOT negotiable and is copied verbatim from the reference
# (crates/qbz/Cargo.toml:72): "async-std" is rfd's name for the
# async-io/ashpd backend. NEVER enable rfd's "tokio" feature — zbus 5's
# tokio feature unifies graph-wide and breaks the accesskit/atspi/ksni
# threads. `default-features = false` keeps the GTK3 backend out; the
# xdg-portal path is the Wayland-correct one and needs no extra system libs.
rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "async-std"] }
[build-dependencies]
cxx-qt-build = "0.7"
+34 -3
View File
@@ -43,12 +43,21 @@ fn main() {
.qt_module("QuickControls2")
.qml_module(QmlModule {
uri: "com.blitzfc.qbz",
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs"],
// EVERY #[cxx_qt::bridge] file. A bridge missing here does not
// fail the build: its QML singleton simply does not exist, and
// every `QbzFoo.bar()` in QML becomes a runtime ReferenceError
// that `cargo check` cannot see. The four MyQBZ/Blacklist
// singletons are the newest arrivals; the domain CONTROLLERS
// (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain
// modules and must NOT be listed — only files that declare a
// #[cxx_qt::bridge] mod belong in this array.
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs"],
qml_files: &[
"qml/LoginScreen.qml",
"qml/Main.qml",
"qml/cards/AlbumCard.qml",
"qml/cards/ArtistCard.qml",
"qml/cards/CollectionMosaic.qml",
"qml/cards/LabelCard.qml",
"qml/cards/MixArtwork.qml",
"qml/cards/PlaylistCard.qml",
@@ -56,22 +65,33 @@ fn main() {
"qml/cards/RadioCard.qml",
"qml/cards/SlimCard.qml",
"qml/cards/TrackCard.qml",
"qml/controls/AddToMixtapeModal.qml",
"qml/controls/CardMenu.qml",
"qml/controls/CardOverlayButton.qml",
"qml/controls/CardOverlayRow.qml",
"qml/controls/GroupHeader.qml",
"qml/controls/MyQbzModals.qml",
"qml/controls/QbzCircleAction.qml",
"qml/controls/QbzConfirmModal.qml",
"qml/controls/QbzContextMenu.qml",
"qml/controls/QbzEmptyState.qml",
"qml/controls/QbzIconButton.qml",
"qml/controls/QbzLineEdit.qml",
"qml/controls/QbzLoadingDots.qml",
"qml/controls/QbzMultiSelectBar.qml",
"qml/controls/QbzNavButton.qml",
"qml/controls/QbzOfflinePlaceholder.qml",
"qml/controls/QbzPrimaryButton.qml",
"qml/controls/QbzRadioOption.qml",
"qml/controls/QbzSectionHeader.qml",
"qml/controls/QbzSegToggle.qml",
"qml/controls/QbzSelect.qml",
"qml/controls/QbzSlider.qml",
"qml/controls/QbzTabBar.qml",
"qml/controls/QbzTextArea.qml",
"qml/controls/QbzToast.qml",
"qml/controls/QbzToggle.qml",
"qml/controls/QbzToolButton.qml",
"qml/controls/QbzTooltip.qml",
"qml/controls/QualityBadge.qml",
"qml/controls/QualityMini.qml",
@@ -79,6 +99,7 @@ fn main() {
"qml/controls/SettingsButton.qml",
"qml/controls/SettingsDivider.qml",
"qml/controls/SettingsSpacer.qml",
"qml/rows/BlacklistRow.qml",
"qml/rows/TrackCols.qml",
"qml/rows/TrackListHeader.qml",
"qml/rows/TrackRow.qml",
@@ -105,6 +126,7 @@ fn main() {
"qml/theme/RoundedImage.qml",
"qml/views/AlbumView.qml",
"qml/views/ArtistView.qml",
"qml/views/BlacklistManagerView.qml",
"qml/views/HomeView.qml",
"qml/views/LibraryView.qml",
"qml/views/LocalLibraryView.qml",
@@ -128,6 +150,7 @@ fn main() {
"qml/controls/DiscoverConfigModal.qml",
"qml/controls/GenreFilterPopup.qml",
"qml/shell/NavFlyout.qml",
"qml/shell/NavSectionGlyph.qml",
"qml/controls/HeaderGradient.qml",
"qml/controls/QbzJumpNavBar.qml",
"qml/shell/LyricsControlsFlyout.qml",
@@ -176,10 +199,8 @@ fn main() {
"qml/views/local/LocalFilterPopup.qml",
"qml/views/local/LocalFolderDetail.qml",
"qml/views/local/LocalFoldersTab.qml",
"qml/views/local/LocalMultiSelectBar.qml",
"qml/views/local/LocalNote.qml",
"qml/views/local/LocalSearchBox.qml",
"qml/views/local/LocalSegToggle.qml",
"qml/views/local/LocalToolbar.qml",
"qml/views/local/LocalTrackRow.qml",
"qml/views/local/LocalTracksTab.qml",
@@ -188,6 +209,16 @@ fn main() {
"qml/views/local/SourceIcon.qml",
"qml/views/local/TreeRow.qml",
"qml/views/local/VersionPicker.qml",
// MyQBZ (Mixtapes / Collections / Artist-Collection builder).
// Its own subdirectory, like views/local/, because the five
// files only ever mount each other.
"qml/views/myqbz/DiscoBuilderView.qml",
"qml/views/myqbz/DiscoCandidateRow.qml",
"qml/views/myqbz/MyQbzCard.qml",
"qml/views/myqbz/MyQbzDetailCard.qml",
"qml/views/myqbz/MyQbzDetailRow.qml",
"qml/views/myqbz/MyQbzDetailView.qml",
"qml/views/myqbz/MyQbzGridView.qml",
],
qrc_files: &qrc_refs,
..Default::default()
+13
View File
@@ -118,6 +118,19 @@ ApplicationWindow {
QbzLyrics.boot()
QbzCast.boot()
QbzBridge.boot()
// MyQBZ (two grids + detail + modals), the app-wide Add picker, the
// Artist-Collection builder, and the Blacklist manager.
//
// boot() is what registers each singleton's CxxQtThread hop, so a
// missing line here is a SILENT no-op forever: the view mounts, every
// `ui()` publish from Rust is dropped on the floor, the document stays
// at its "{}" default and NOTHING is logged on either side. QbzBlacklist
// is the loudest case — its `blacklistLoading` defaults to true, so the
// manager would spin forever.
QbzMyQbz.boot()
QbzMyQbzAdd.boot()
QbzDisco.boot()
QbzBlacklist.boot()
// Seed the maximized latch ONCE, imperatively — see its declaration for
// why it must not be a binding on the bridge property.
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.801 10A10 10 0 1 1 17 3.335" />
<path d="m9 11 3 3L22 4" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>

After

Width:  |  Height:  |  Size: 322 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" />
<circle cx="9" cy="13" r="5" />
<circle cx="9" cy="13" r="1" />
<path d="m18 7-4.2 4.2" />
</svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#e0b341" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#e0b341" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.801 10A10 10 0 1 1 17 3.335" />
<path d="m9 11 3 3L22 4" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#e0b341" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#e0b341" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>

After

Width:  |  Height:  |  Size: 322 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#e0b341" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" />
<circle cx="9" cy="13" r="5" />
<circle cx="9" cy="13" r="1" />
<path d="m18 7-4.2 4.2" />
</svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.801 10A10 10 0 1 1 17 3.335" />
<path d="m9 11 3 3L22 4" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>

After

Width:  |  Height:  |  Size: 322 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" />
<circle cx="9" cy="13" r="5" />
<circle cx="9" cy="13" r="1" />
<path d="m18 7-4.2 4.2" />
</svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.801 10A10 10 0 1 1 17 3.335" />
<path d="m9 11 3 3L22 4" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>

After

Width:  |  Height:  |  Size: 322 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>

After

Width:  |  Height:  |  Size: 236 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.801 10A10 10 0 1 1 17 3.335" />
<path d="m9 11 3 3L22 4" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>

After

Width:  |  Height:  |  Size: 322 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" />
<circle cx="9" cy="13" r="5" />
<circle cx="9" cy="13" r="1" />
<path d="m18 7-4.2 4.2" />
</svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.801 10A10 10 0 1 1 17 3.335" />
<path d="m9 11 3 3L22 4" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>

After

Width:  |  Height:  |  Size: 322 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" />
<circle cx="9" cy="13" r="5" />
<circle cx="9" cy="13" r="1" />
<path d="m18 7-4.2 4.2" />
</svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.801 10A10 10 0 1 1 17 3.335" />
<path d="m9 11 3 3L22 4" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>

After

Width:  |  Height:  |  Size: 322 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" />
<circle cx="9" cy="13" r="5" />
<circle cx="9" cy="13" r="1" />
<path d="m18 7-4.2 4.2" />
</svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.801 10A10 10 0 1 1 17 3.335" />
<path d="m9 11 3 3L22 4" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>

After

Width:  |  Height:  |  Size: 322 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" />
<circle cx="9" cy="13" r="5" />
<circle cx="9" cy="13" r="1" />
<path d="m18 7-4.2 4.2" />
</svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1,316 @@
// CollectionMosaic — the artwork renderer for Mixtape / Collection cards, list
// rows, empty states and the detail hero (myqbz/CollectionMosaic.slint:1-121).
// Four sizes in use: 184 (grid card), 48 (list row), 160 (empty state), 186
// (detail hero).
//
// SIBLING FILE: cards/PlaylistCollage.qml. Read that one first — the tile
// discipline, the URL-keyed artwork resolver, the 120ms dispatch debounce and
// the single outer mask are copied from it deliberately. Two differences:
// * this grid is UNIFORM (2x2 or 3x3) instead of the playlist collage's
// single / dual / 62-38 / 34-66 ratios, and
// * `urls` is INDEX-SIGNIFICANT (Slint's url1..url9), so PlaylistCollage's
// `dedup()` must NOT be copied: cell N is `urls[N]` and Rust already
// guarantees distinct covers.
// It is a separate file rather than a `layout` parameter on PlaylistCollage
// because the tiles are static children: nine of them would mount on every
// playlist card on every Home rail, every Library grid cell and every sidebar
// row, app-wide.
//
// ── HOW THE TILES ARE DRAWN (this file used to be a Canvas) ────────────────
// Each cell is a `RoundedImage { radius: 0; fit: "crop" }`, i.e. a plain
// filtered `Image` that requests its own scaled derivative at its own device
// size through the existing `QbzSession.artScaled` seam. The first version of
// this file was a per-card `Canvas` whose `Context2D.drawImage` does NOT
// filter when it scales — a 150px cover drawn into a 23px list-row cell was
// EXACTLY nearest-neighbour (measured RMSE 0.000 against `-filter Point`,
// theme/RoundedImage.qml). That is the same defect perf FIX 1 / FIX 4 removed
// from RoundedImage and PlaylistCollage; writing it a third time here was the
// bug. Reuse over a third copy: RoundedImage already owns the intrinsic-size
// latch, the derivative request, the crop fit, the readiness latch, the
// per-item crop scissor (`clip` at radius 0, spec 04 §1.3) and the
// recycled-delegate invalidation.
//
// Only the mosaic's OUTER shape is rounded; the cells are square-cornered.
// That rounding is one `layer.enabled` + one `MultiEffect` mask on the whole
// mosaic (NOT one per cell), the mechanism theme/RoundedImage.qml documents.
// Effects need shaders: this port runs on the GPU (OpenGL RHI, measured
// 2026-07-29); the earlier "shader masks render nothing" note in this header
// came from an offscreen session, which forces the software renderer by
// definition. Where a software path is genuinely possible it is DETECTED
// (`GraphicsInfo.api`), and `QbzShell.forceCanvasArt` pins the same degrade by
// hand — in either case the mask is skipped and the mosaic reads
// square-cornered over the already-rounded placeholder fill, rather than
// blank.
//
// THE RULES, IN PRECEDENCE ORDER (CollectionMosaic.slint:61-120):
// 1. `hasCustomCover` -> ONE full-bleed crop-fitted cover. The gate is
// `hasCustomCover` ALONE (:62), NOT "and the path resolved": an
// unresolved custom cover paints the bare surface-elevated outer, exactly
// as Slint does. (Rust's card predicate and hero predicate differ on
// purpose — myqbz.rs:322-331 vs :367 — so a deleted cover file can give
// glyph-in-hero / mosaic-in-grid. Do not unify them here.)
// 2. else `coverCount == 0` -> the centred kind glyph at round(size * 0.4),
// muted, opacity 0.6 (:70-82).
// 3. else the collage. `cols = (effKind == "collection" && itemCount >= 9)
// ? 3 : 2` (:58), gap 2 (:59). Empty cells show the outer
// surface-elevated through the seam, which is what Slint's empty
// MosaicCell paints anyway (:32).
//
// EFFECTIVE KIND: `emptyKind !== "" ? emptyKind : kind` (:56). The override is
// NOT empty-state-only — it feeds BOTH the glyph choice and the 3x3 rule, so
// it is computed once and used in both places.
//
// Rust pre-decides `coverCount` (0 / 4 / 9) and pre-downscales each cell URL
// (_50 for the 3x3, _150 for the 2x2 — myqbz_qt.rs:403-417); this component
// only lays out. Never point a cell at a _600 source.
import QtQuick
import QtQuick.Effects
import com.blitzfc.qbz
import "../theme"
Item {
id: root
/// 0..9 remote cover URLs, INDEX-SIGNIFICANT (Slint's url1..url9).
property var urls: []
/// Optional pre-resolved `file://` paths from the document (GridDoc
/// `cellPaths` / DetailDoc `heroCellPaths`), same indexing; "" = pending,
/// in which case the URL-keyed resolver below fills it in.
property var paths: []
property int coverCount: 0
/// item.kind — mixtape | collection | artist_collection.
property string kind: ""
/// Caller override of the EFFECTIVE kind (glyph AND the 3x3 rule).
property string emptyKind: ""
/// Drives the 3x3 rule.
property int itemCount: 0
property bool hasCustomCover: false
/// Resolved local file:// for the custom cover.
property string customCoverPath: ""
property real size: 184
property real gap: 2
property real radius: 8
QbzTheme { id: theme }
width: root.size
height: root.size
readonly property string effKind: root.emptyKind !== "" ? root.emptyKind : root.kind
readonly property int cols: (root.effKind === "collection" && root.itemCount >= 9) ? 3 : 2
readonly property int cellCount: root.cols === 3 ? 9 : 4
readonly property bool collage: !root.hasCustomCover && root.coverCount > 0
/// See theme/RoundedImage.qml: the ONLY case the retired "effects render
/// nothing" doctrine was ever true for is the software/Null renderer, and
/// `GraphicsInfo.api` answers it per window at runtime (`api` carries
/// `notify: "apiChanged"`, so this re-evaluates when the window arrives).
/// Test Software/Null negatively — under the RHI an OpenGL backend reports
/// `GraphicsInfo.OpenGL`.
///
/// `QbzShell.forceCanvasArt` (env `QBZ_QT_ROUND_MODE=canvas`, default OFF)
/// is honoured HERE TOO, for the reason PlaylistCollage.qml:86-96 states:
/// the override exists for a machine where `GraphicsInfo` reports a GPU and
/// the mask still misbehaves, and if the mask draws nothing there then THIS
/// file's mask draws nothing either — the whole mosaic disappears. Pinning
/// the cells onto RoundedImage's canvas arm while leaving the mosaic masked
/// would defeat the override on exactly the surface it was added for.
/// Square outer corners (the same degrade the software path takes) is the
/// correct fallback. The `typeof` guard mirrors RoundedImage: eager
/// binding, and the file must stay loadable in an isolated qml6 scene.
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
|| (typeof QbzShell !== "undefined" && QbzShell.forceCanvasArt)
// url -> local cached path, filled by the artwork signal.
property var pathMap: ({})
function urlAt(index) {
const u = root.urls
return (u && index < u.length && u[index]) ? u[index] : ""
}
function pathAt(index) {
const seeded = (root.paths && index < root.paths.length) ? root.paths[index] : ""
if (seeded)
return seeded
const u = root.urlAt(index)
return u ? (root.pathMap[u] || "") : ""
}
/// Uniform grid, transcribed from CollectionMosaic.slint: 2x2 cell
/// `(size - gap) / 2` (:89-95), 3x3 cell `(size - 2 * gap) / 3` (:104-118),
/// `gap` 2 (:59). Cell N sits at `col * (cell + gap)` / `row * (cell + gap)`
/// — the same arithmetic the deleted `onPaint` ran. These numbers do not
/// change.
function cellOf(index) {
const w = root.width
const h = root.height
const n = root.cols
if (w <= 0 || h <= 0 || index >= n * n)
return Qt.rect(0, 0, 0, 0)
const g = root.gap
const cell = (w - (n - 1) * g) / n
return Qt.rect((index % n) * (cell + g),
Math.floor(index / n) * (cell + g),
cell, cell)
}
// --- Outer placeholder. A Rectangle's OWN rounded fill paints correctly;
// only its children would need the mask.
Rectangle {
anchors.fill: parent
radius: root.radius
color: theme.surfaceElevated
}
// --- Rule 1: custom cover, full-bleed crop. Loader-gated so the mask is
// not instantiated on the (overwhelmingly common) cards that have no
// custom cover.
Loader {
anchors.fill: parent
active: root.hasCustomCover
sourceComponent: customCover
}
Component {
id: customCover
RoundedImage {
source: root.customCoverPath
radius: root.radius
fit: "crop"
}
}
// --- Rule 2: the centred kind glyph.
QbzIcon {
visible: !root.hasCustomCover && root.coverCount === 0
name: root.effKind === "mixtape" ? "cassette-tape"
: root.effKind === "artist_collection" ? "user"
: "library-big"
width: Math.round(root.size * 0.4)
height: width
anchors.centerIn: parent
tintName: "muted"
opacity: 0.6
}
// --- Artwork: URL-keyed through the SHARED resolver (the sidebar
// micro-collage channel), debounced so flinging a windowed grid never
// fetches covers for delegates that are already gone. Only cells whose
// path is still unknown are asked for. No new bridge member.
Timer {
id: dispatchDelay
interval: 120
onTriggered: {
var pending = []
for (var i = 0; i < root.cellCount; i++) {
var u = root.urlAt(i)
if (u !== "" && root.pathAt(i) === "")
pending.push(u)
}
if (pending.length > 0)
QbzShell.sidebarArtworkWindow(JSON.stringify(pending))
}
}
function kick() {
if (root.collage)
dispatchDelay.restart()
}
onUrlsChanged: root.kick()
Component.onCompleted: root.kick()
Connections {
target: QbzLibrary
// Shared with the feed's artKey-keyed emissions — ignore anything that
// is not one of OUR urls.
function onLibraryArtworkReady(key, path) {
if (root.pathMap[key] === path)
return
var mine = false
for (var i = 0; i < root.cellCount; i++) {
if (root.urlAt(i) === key) { mine = true; break }
}
if (!mine)
return
var m = root.pathMap
m[key] = path
// Rebind requires a NEW object reference.
root.pathMap = Object.assign({}, m)
}
}
/// The rounded OUTER shape, rendered to its own FBO once (it changes only
/// with width/height/radius, never per frame). `visible: false` + layered
/// is the documented MultiEffect mask idiom — the layer renders regardless
/// of visibility, which is the whole point.
Item {
id: maskSrc
anchors.fill: parent
visible: false
// Also gated on `collage` — a layer renders even when its item is
// invisible (that is the mask idiom), so a glyph-only card would
// otherwise pay two FBOs to mask a glyph that is not inside them.
layer.enabled: !root._noShaders && root.radius > 0 && root.collage
layer.smooth: true
Rectangle {
anchors.fill: parent
radius: root.radius
color: "#ffffff"
}
}
// --- Rule 3: the collage. ONE layer + ONE mask for the whole mosaic, not
// one per cell. `clip: true` bounds the outer rect; each cell's own crop
// overflow is scissored by RoundedImage itself (`clip` there covers exactly
// the radius-0 crop case this file creates — the deleted Canvas clipped per
// cell with `ctx.rect(); ctx.clip()`).
Item {
id: mosaic
anchors.fill: parent
clip: true
visible: root.collage
layer.enabled: !root._noShaders && root.radius > 0 && root.collage
layer.smooth: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: maskSrc
// 0.5 / 1.0 — MEASURED, see theme/RoundedImage.qml:491-519 for the
// full table. Qt's mask is a smoothstep CENTRED on maskThresholdMin
// of width maskSpreadAtMin, so (0.5, 1.0) is the only pair that maps
// mask alpha 0..1 onto 0..1 and therefore the only one that
// reproduces `ctx.clip()`'s antialiased corner. The comment that
// used to sit here had it backwards: (0.0, 0.0) does NOT "leave
// everything alpha-blending", it collapses the smoothstep to
// `step(0, alpha)` — every mask pixel with any alpha snaps fully
// opaque, so the corner comes back HARD (measured at radius 40 on
// this Qt 6.11.1: 0 antialiased pixels against the Canvas's 292,
// and 308 at 0.5/1.0 = Canvas parity, ramp [0,51,219,255]).
// (0.0, > 0) is worse: the low edge goes negative, everything
// clamps opaque and the mask is disabled outright. Do not "restore
// the defaults" here.
maskThresholdMin: 0.5
maskSpreadAtMin: 1.0
}
Repeater {
// 4 for the 2x2, 9 for the 3x3, and ZERO when this card shows a
// custom cover or the kind glyph — an idle tile is still an Item.
model: root.collage ? root.cellCount : 0
delegate: RoundedImage {
required property int index
readonly property rect cell: root.cellOf(index)
x: cell.x
y: cell.y
width: cell.width
height: cell.height
source: root.pathAt(index)
// Square-cornered: only the mosaic's outer shape is rounded.
// radius 0 also means this RoundedImage mounts a plain Image
// with NO layer and NO mask of its own, and takes the crop
// scissor instead (spec 04 §1.3).
radius: 0
fit: "crop"
}
}
}
}
@@ -0,0 +1,620 @@
// AddToMixtapeModal — the app-wide "Add to Mixtape/Collection" picker
// (myqbz/AddToMixtapeModal.slint, 553 lines). A GLOBAL singleton mounted ONCE
// in AppShell.qml (Slint mounts it the same way at AppShell.slint:824) and
// self-gated on its own document's `open` flag, so every caller only has to
// invoke `QbzMyQbzAdd.open(itemsJson)`.
//
// It carries its OWN backdrop and card — NOT the shared modal chrome (:5) —
// and TWO mutually exclusive body panels: the PICKER (search row + collection
// list + the two create chips) and the CREATE-NEW panel (name + kind +
// footer).
//
// Chrome (:209-231): scrim `#00000099` that closes on click, card
// `min(width - 80, 480) x min(height - 120, 560)` centred, r12 surface-card,
// 1px border-subtle, and a click-swallowing area over the card so a press
// inside it never reaches the scrim. The scrim carries `radius:
// theme.radiusMd` for the reason controls/DiscoverConfigModal.qml:59-75 spells
// out: QML `clip` is a rectangular scissor, so an opaque full-bleed child
// paints into the shell frame's four bezel corners and the panel reads square.
// The .slint's `drop-shadow-blur: 32px` is dropped — shadows render nothing on
// this port's software path; the 1px border carries the separation.
//
// The header's title/subtitle are PRECOMPUTED IN RUST (single item ->
// title+subtitle; bulk -> "{count} items" + "{first} + N more"), so this file
// never inspects the pending items.
//
// The name typed into the create panel is QML-LOCAL: the bridge takes it as an
// argument (`createAndAdd(kind, name)`) and has no name property. Same for the
// create KIND, which the two radios flip locally over the doc's value — there
// is no `setCreateKind` invokable, and re-calling `showCreate` for a radio tap
// would be a round trip for a value the submit call already carries.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// Guarded parse — a raw JSON.parse in a binding throws on the pre-publish
// frame and takes the whole overlay down (PlaylistView.qml:55-61).
readonly property var doc: {
try {
return JSON.parse(QbzMyQbzAdd.addJson)
} catch (e) {
return ({})
}
}
readonly property bool opened: root.doc.open === true
readonly property bool loading: root.doc.loading === true
readonly property bool creating: root.doc.creating === true
readonly property bool createBusy: root.doc.createBusy === true
readonly property bool restrictToMixtape: root.doc.restrictToMixtape === true
readonly property string busyId: root.doc.busyId || ""
readonly property string search: root.doc.search || ""
readonly property var rows: root.doc.rows || []
// Local create-panel state (see the header).
property string createName: ""
property string createKindOverride: ""
readonly property string docCreateKind: root.doc.createKind || "mixtape"
readonly property string createKind: root.createKindOverride !== ""
? root.createKindOverride : root.docCreateKind
readonly property bool createMixtape: root.createKind === "mixtape"
// :207 — the raw non-empty check, exactly as Slint gates it (Rust trims).
readonly property bool canCreate: root.createName !== "" && !root.createBusy
// A fresh panel starts empty and follows the kind Rust was asked for; a
// closed modal must not keep a half-typed name for the next caller.
onDocCreateKindChanged: root.createKindOverride = ""
onCreatingChanged: if (root.creating) root.createName = ""
onOpenedChanged: if (!root.opened) { root.createName = ""; root.createKindOverride = "" }
visible: root.opened
enabled: root.opened
// --- Scrim. Declared FIRST so the card (later in source order, hence
// above it) keeps its own presses.
Rectangle {
anchors.fill: parent
radius: theme.radiusMd
color: "#99000000"
MouseArea {
anchors.fill: parent
onClicked: QbzMyQbzAdd.close()
}
}
Rectangle {
id: card
width: Math.min(root.width - 80, 480)
height: Math.min(root.height - 120, 560)
x: Math.round((root.width - width) / 2)
y: Math.round((root.height - height) / 2)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
clip: true
// Swallow clicks so they never reach the scrim.
MouseArea { anchors.fill: parent }
// ======================= HEADER (always) ======================
Item {
id: header
width: parent.width
// :235 — the row's own height plus 20 top + 16 bottom.
height: Math.max(hdrText.implicitHeight, 28) + 36
Column {
id: hdrText
x: 20
y: 20
width: Math.max(0, card.width - 40 - 28)
spacing: 2
Text {
text: root.t("ADD TO MIXTAPE/COLLECTION")
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Text {
width: parent.width
text: root.doc.headerTitle || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
Text {
visible: (root.doc.headerSubtitle || "") !== ""
width: parent.width
text: root.doc.headerSubtitle || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
Item {
x: card.width - 20 - 28
y: 20
width: 28
height: 28
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 17
height: 17
tintName: hdrCloseArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: hdrCloseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzMyQbzAdd.close()
}
}
// Bottom border on the block's LAST pixel (:285-289).
Rectangle {
y: parent.height - 1
width: parent.width
height: 1
color: theme.borderSubtle
}
}
// ======================== PICKER PANEL ========================
Item {
id: picker
visible: !root.creating
anchors.top: header.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
// --- Search row (:295-361). The leading glyph sits OUTSIDE the
// box, which is unique to this modal — QbzLineEdit puts its
// magnifier inside — so the box is inlined here rather than
// forking the shared control.
Item {
id: searchRow
width: parent.width
height: 46
QbzIcon {
x: 20
anchors.verticalCenter: searchBox.verticalCenter
name: "search"
width: 14
height: 14
tintName: "muted"
}
Rectangle {
id: searchBox
x: 20 + 14 + 8
width: Math.max(0, parent.width - x - 20)
height: 43
y: Math.round((parent.height - height) / 2)
radius: theme.radiusSm
color: theme.surfaceCard
border.width: 1
border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle
TextInput {
id: searchInput
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
color: theme.textPrimary
font.pixelSize: theme.fontLink
verticalAlignment: Text.AlignVCenter
clip: true
selectByMouse: true
text: root.search
onTextEdited: QbzMyQbzAdd.search(text)
// A republish re-seeds the field while it is not being
// edited (QbzLineEdit.qml:167-172).
Binding {
target: searchInput
property: "text"
value: root.search
when: !searchInput.activeFocus
}
}
Text {
visible: searchInput.text === ""
anchors.fill: searchInput
text: root.t("Search…")
color: theme.textMuted
font.pixelSize: theme.fontLink
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
Rectangle {
y: parent.height - 1
width: parent.width
height: 1
color: theme.borderSubtle
}
}
// --- Footer: the two create chips (:403-432). 60px, elevated, a
// 1px rule at its TOP (:406-409 puts it at y=0), padding 12.
Rectangle {
id: pickerFooter
anchors.bottom: parent.bottom
width: parent.width
height: 60
color: theme.surfaceElevated
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
Row {
anchors.fill: parent
anchors.margins: 12
spacing: 8
// Both chips carry `horizontal-stretch: 1`, so one fills
// the row and two split it.
readonly property int chips: root.restrictToMixtape ? 1 : 2
readonly property real chipW: (width - (chips - 1) * 8) / chips
FooterIconButton {
anchors.verticalCenter: parent.verticalCenter
width: parent.chipW
label: root.t("Mixtapes")
onClicked: QbzMyQbzAdd.showCreate("mixtape")
}
FooterIconButton {
// Hidden for track/playlist payloads: collections hold
// whole albums only (:423).
anchors.verticalCenter: parent.verticalCenter
visible: !root.restrictToMixtape
width: visible ? parent.chipW : 0
label: root.t("Collections")
onClicked: QbzMyQbzAdd.showCreate("collection")
}
}
}
// --- Between the two: spinner / empty band / the list.
Item {
anchors.top: searchRow.bottom
anchors.bottom: pickerFooter.top
width: parent.width
// Loading (:364-368): centred, 24px padding, 30px spinner.
QbzSpinner {
visible: root.loading
anchors.horizontalCenter: parent.horizontalCenter
y: 24
size: 30
}
// Empty (:370-381).
Text {
visible: !root.loading && root.rows.length === 0
width: parent.width
height: 80
text: root.search !== "" ? root.t("No matches.")
: root.t("No mixtapes or collections yet.")
color: theme.textMuted
font.pixelSize: theme.fontBody
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
// List (:384-400).
ListView {
id: pickList
visible: !root.loading && root.rows.length > 0
anchors.fill: parent
// The .slint's `padding: 8` on the list layout. The
// horizontal half is on the DELEGATE, not as a Flickable
// margin: a vertical ListView's leftMargin moves the
// content origin, it does not inset the rows.
topMargin: 8
bottomMargin: 8
spacing: 2
cacheBuffer: 200
reuseItems: true
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.rows
delegate: AddPickRow {
required property var modelData
required property int index
x: 8
width: Math.max(0, pickList.width - 16)
rowData: modelData
rowIndex: index
}
}
}
}
// ====================== CREATE-NEW PANEL ======================
Item {
visible: root.creating
anchors.top: header.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
Column {
id: createTop
x: 20
y: 20
width: Math.max(0, parent.width - 40)
spacing: 16
// --- NAME
Column {
width: parent.width
spacing: 8
Text {
text: root.t("NAME")
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Rectangle {
width: parent.width
height: 43
radius: theme.radiusSm
color: theme.surfaceCard
border.width: 1
border.color: nameInput.activeFocus ? theme.accent : theme.borderSubtle
TextInput {
id: nameInput
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
enabled: !root.createBusy
color: theme.textPrimary
font.pixelSize: theme.fontLink
verticalAlignment: Text.AlignVCenter
clip: true
selectByMouse: true
onTextEdited: root.createName = text
onAccepted: {
if (root.canCreate)
QbzMyQbzAdd.createAndAdd(root.createKind, root.createName)
}
Binding {
target: nameInput
property: "text"
value: root.createName
when: !nameInput.activeFocus
}
}
Text {
visible: nameInput.text === ""
anchors.fill: nameInput
// NOT translated — verbatim literals in the .slint
// (:482).
text: root.createMixtape ? "90s Cassettes" : "Jazz Library"
color: theme.textMuted
font.pixelSize: theme.fontLink
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
// --- KIND (16px between the radios here; the Create modal
// uses 12 — CreateMyQbzModal.slint:150).
Column {
width: parent.width
spacing: 8
Text {
text: root.t("KIND")
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Row {
spacing: 16
QbzRadioOption {
label: root.t("Mixtapes")
selected: root.createMixtape
onClicked: if (!root.createBusy) root.createKindOverride = "mixtape"
}
QbzRadioOption {
label: root.t("Collections")
selected: !root.createMixtape
onClicked: if (!root.createBusy) root.createKindOverride = "collection"
}
}
}
}
// Footer, pushed to the bottom by the .slint's vertical stretch
// spacer (:526).
Row {
anchors.right: parent.right
anchors.rightMargin: 20
anchors.bottom: parent.bottom
anchors.bottomMargin: 20
spacing: 8
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: root.t("Back")
btnHeight: 38
minWidth: 0
enabled: !root.createBusy
onClicked: QbzMyQbzAdd.createBack()
}
QbzPrimaryButton {
anchors.verticalCenter: parent.verticalCenter
label: root.t("Create & Add")
btnHeight: 38
btnEnabled: root.canCreate
onClicked: QbzMyQbzAdd.createAndAdd(root.createKind, root.createName)
}
}
}
}
// One collection row in the picker list (:63-158). 52px, r8, zebra on ODD
// indices, dimmed and inert while ANOTHER row is being written to.
component AddPickRow: Rectangle {
id: pickRow
// NOT `data`: Item already owns a `data` default property.
property var rowData: ({})
property int rowIndex: 0
readonly property bool busy: root.busyId !== ""
height: 52
radius: 8
opacity: pickRow.busy ? 0.5 : 1.0
// TrackRow.qml:200-206's exact pair: the hover is the polarity-baked
// alpha ramp (so it is visible on light themes too) with the
// pre-publish fallback, and the zebra stays the dark literal.
color: (pickHover.containsMouse && !pickRow.busy)
? (theme.alphaTiers.length > 0 ? theme.alphaTier(8)
: (theme.isDark ? "#14ffffff" : "#14000000"))
: (pickRow.rowIndex % 2 === 1 ? "#07ffffff" : "transparent")
MouseArea {
id: pickHover
anchors.fill: parent
hoverEnabled: true
cursorShape: pickRow.busy ? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: if (!pickRow.busy) QbzMyQbzAdd.pick(pickRow.rowData.id)
}
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 12
Item {
anchors.verticalCenter: parent.verticalCenter
width: 32
height: 32
QbzIcon {
anchors.centerIn: parent
// Rust resolves the name (myqbz_add.rs:203-209).
name: pickRow.rowData.icon === "user" ? "user"
: pickRow.rowData.icon === "library-big" ? "library-big"
: "cassette-tape"
width: 18
height: 18
tintName: "muted"
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, parent.width - 32 - 12 - kindTag.width - 12)
Text {
width: parent.width
text: pickRow.rowData.name || ""
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Row {
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
text: pickRow.rowData.meta || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
// "Already added" — the collection already holds EVERY
// pending item (Rust's item_exists pass).
Row {
visible: pickRow.rowData.alreadyHas === true
anchors.verticalCenter: parent.verticalCenter
spacing: 3
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
name: "circle-check-big"
width: 12
height: 12
tintName: "accent"
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.t("Already added")
color: theme.accent
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
}
}
}
Text {
id: kindTag
anchors.verticalCenter: parent.verticalCenter
text: pickRow.rowData.kindLabel || ""
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 1.2
}
}
}
// The "+ Mixtapes" / "+ Collections" footer chips (:164-201): a 38px
// labelled icon button with a CENTRED [glyph · gap · label] group. Neither
// SettingsButton (34px, 160 min width) nor QbzToolButton (30px) is this
// shape, and both call sites are in this file, so it stays local.
component FooterIconButton: Rectangle {
id: chip
property string label: ""
signal clicked()
height: 38
radius: theme.radiusSm
color: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Row {
anchors.centerIn: parent
spacing: 6
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
name: "plus"
width: 14
height: 14
tintName: "textPrimary"
}
Text {
anchors.verticalCenter: parent.verticalCenter
// The leading `+` is the GLYPH above, not part of the msgid.
text: chip.label
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: chipArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: chip.clicked()
}
}
}
+516
View File
@@ -0,0 +1,516 @@
// MyQbzModals — the THREE small MyQBZ modals in one file, as three sibling
// panels: Create (myqbz/CreateMyQbzModal.slint, 199 lines), Edit
// (MyQbzEditModal.slint, 147) and Mix (MyQbzMixModal.slint, 184). Slint keeps
// them apart only because each has its own global; they share the chrome
// byte-for-byte (`#bf000000` backdrop that closes on click, a centred
// surface-card panel with a 1px border-subtle border and 24px padding, a
// right-aligned footer of one secondary + one primary button) and they are
// mutually exclusive in practice.
//
// A GLOBAL overlay: AppShell.qml mounts it once and each panel self-gates on
// its OWN document's `open` flag (`QbzMyQbz.createJson` / `editJson` /
// `mixJson`) — the same shape as Slint's four bare instantiations at
// AppShell.slint:814-824.
//
// Per-panel numbers that are NOT shared, and every one of them is deliberate:
// * width 420 / 420 / 440 (Create / Edit / Mix);
// * spacing 16 / 16 / 18;
// * title `Typography.heading` = 19 in Create, but a LITERAL 18 in Edit
// (MyQbzEditModal.slint:70) and Mix (MyQbzMixModal.slint:80);
// * a 28x28 close X in Create — Edit and Mix have NONE;
// * footer spacing 8 / 8 / 12 (MyQbzMixModal.slint:159).
//
// Drop shadows (`drop-shadow-blur: 32px` on all three) are dropped: shadows
// render nothing on this port's software path. The scrims carry `radius:
// theme.radiusMd` so an opaque full-bleed child cannot paint into the shell
// frame's bezel corners (controls/DiscoverConfigModal.qml:59-75).
//
// TEXT STATE IS QML-LOCAL. `MyQbzCreateState.name` and
// `MyQbzEditState.draft-name` / `draft-description` are NOT bridge members:
// every submit invokable takes the value as an argument (`createSubmit(name)`,
// `editSubmitRename(name)`, `editSubmitDescription(description)`). The drafts
// are seeded from the document when a panel opens and cleared when it closes.
//
// ENTER submits through `QbzLineEdit`'s `accepted` signal, NEVER `committed`:
// `committed` also fires on focus-out (QbzLineEdit.qml:155), and a blur must
// not create or rename anything.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// --- Documents (guarded parses; a raw JSON.parse in a binding throws on
// the pre-publish frame — PlaylistView.qml:55-61).
readonly property var createDoc: {
try { return JSON.parse(QbzMyQbz.createJson) } catch (e) { return ({}) }
}
readonly property var editDoc: {
try { return JSON.parse(QbzMyQbz.editJson) } catch (e) { return ({}) }
}
readonly property var mixDoc: {
try { return JSON.parse(QbzMyQbz.mixJson) } catch (e) { return ({}) }
}
readonly property bool createOpen: root.createDoc.open === true
readonly property bool editOpen: root.editDoc.open === true
readonly property bool mixOpen: root.mixDoc.open === true
visible: root.createOpen || root.editOpen || root.mixOpen
enabled: root.visible
// ================================================================
// PANEL 1 — Create Mixtape / Collection (CreateMyQbzModal.slint)
// ================================================================
Item {
id: createPanel
anchors.fill: parent
visible: root.createOpen
readonly property string kind: root.createDoc.kind || "mixtape"
readonly property bool isMixtape: createPanel.kind === "mixtape"
readonly property bool creating: root.createDoc.creating === true
property string name: ""
readonly property bool canCreate: createPanel.name !== "" && !createPanel.creating
// A fresh open starts with an empty field.
Connections {
target: root
function onCreateOpenChanged() { createPanel.name = "" }
}
Rectangle {
anchors.fill: parent
radius: theme.radiusMd
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: QbzMyQbz.createClose()
}
}
Rectangle {
width: Math.min(root.width - 80, 420)
height: createCol.implicitHeight + 48
x: Math.round((root.width - width) / 2)
y: Math.round((root.height - height) / 2)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
MouseArea { anchors.fill: parent }
Column {
id: createCol
x: 24
y: 24
width: parent.width - 48
spacing: 16
// --- Title + close
Item {
width: parent.width
height: 28
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, parent.width - 28)
text: createPanel.isMixtape ? root.t("Create Mixtape")
: root.t("Create Collection")
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightBold
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Item {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 17
height: 17
tintName: createCloseArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: createCloseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzMyQbz.createClose()
}
}
}
// --- NAME
Column {
width: parent.width
spacing: 8
Text {
text: root.t("NAME")
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
QbzLineEdit {
width: parent.width
height: 34
text: createPanel.name
onEdited: function (v) { createPanel.name = v }
onAccepted: function (v) {
if (createPanel.canCreate) QbzMyQbz.createSubmit(createPanel.name)
}
}
}
// --- KIND (12px between the radios here; the Add modal uses
// 16 — AddToMixtapeModal.slint:503).
Column {
width: parent.width
spacing: 8
Text {
text: root.t("KIND")
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Row {
spacing: 12
QbzRadioOption {
label: root.t("Mixtapes")
selected: createPanel.isMixtape
onClicked: if (!createPanel.creating) QbzMyQbz.createSetKind("mixtape")
}
QbzRadioOption {
label: root.t("Collections")
selected: !createPanel.isMixtape
onClicked: if (!createPanel.creating) QbzMyQbz.createSetKind("collection")
}
}
}
// --- Footer
Row {
anchors.right: parent.right
spacing: 8
SettingsButton {
text: root.t("Cancel")
btnHeight: 38
minWidth: 0
onClicked: QbzMyQbz.createClose()
}
QbzPrimaryButton {
label: createPanel.isMixtape ? root.t("+ New Mixtape")
: root.t("+ New Collection")
btnHeight: 38
btnEnabled: createPanel.canCreate
onClicked: QbzMyQbz.createSubmit(createPanel.name)
}
}
}
}
}
// ================================================================
// PANEL 2 — Rename / Edit description / Delete (MyQbzEditModal.slint)
// ================================================================
Item {
id: editPanel
anchors.fill: parent
visible: root.editOpen
readonly property string mode: root.editDoc.mode || ""
readonly property bool isRename: editPanel.mode === "rename"
readonly property bool isDescription: editPanel.mode === "description"
readonly property bool isDelete: editPanel.mode === "delete"
readonly property bool busy: root.editDoc.busy === true
property string draftName: ""
property string draftDescription: ""
// :33-35 — busy blocks everything; rename additionally needs a
// non-empty name (Rust trims again).
readonly property bool canSubmit: editPanel.busy
? false : (editPanel.isRename ? editPanel.draftName !== "" : true)
// Prefill from the document (Rust fills `name` for the rename + the
// delete-confirm interpolation, `description` for the description
// body — myqbz_edit sets the drafts when it opens, which is what this
// reproduces).
//
// Keyed on mode+name, NOT on `open`: Rust may publish a second open
// document for a DIFFERENT collection without an intervening closed
// frame, and then `open` never changes and the prefill would be stale.
// Keying it on the whole document instead would clobber what the user
// is typing every time an unrelated field (`busy`) flips.
readonly property string editKey: editPanel.mode + "|" + (root.editDoc.name || "")
onEditKeyChanged: {
editPanel.draftName = root.editDoc.name || ""
editPanel.draftDescription = root.editDoc.description || ""
}
Rectangle {
anchors.fill: parent
radius: theme.radiusMd
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: QbzMyQbz.editClose()
}
}
Rectangle {
width: Math.min(root.width - 80, 420)
height: editCol.implicitHeight + 48
x: Math.round((root.width - width) / 2)
y: Math.round((root.height - height) / 2)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
MouseArea { anchors.fill: parent }
Column {
id: editCol
x: 24
y: 24
width: parent.width - 48
spacing: 16
// --- Title. A LITERAL 18px (:70), NOT fontHeading, and there
// is deliberately no close X in this modal.
Text {
width: parent.width
text: editPanel.isRename ? root.t("Rename")
: editPanel.isDescription ? root.t("Edit description")
: root.t("Delete")
color: theme.textPrimary
font.pixelSize: 18
font.weight: theme.weightBold
elide: Text.ElideRight
}
// --- Body: rename
QbzLineEdit {
visible: editPanel.isRename
width: parent.width
height: visible ? 34 : 0
text: editPanel.draftName
onEdited: function (v) { editPanel.draftName = v }
onAccepted: function (v) {
if (editPanel.canSubmit) QbzMyQbz.editSubmitRename(editPanel.draftName)
}
}
// --- Body: description
QbzTextArea {
visible: editPanel.isDescription
width: parent.width
height: visible ? 96 : 0
wrapMode: TextEdit.WordWrap
text: editPanel.draftDescription
onEdited: function (v) { editPanel.draftDescription = v }
}
// --- Body: delete confirm (:105, DOUBLE quotes around the
// name, interpolated into the msgid's `{}`).
Text {
visible: editPanel.isDelete
width: parent.width
text: root.t("Delete \"{}\"? This cannot be undone.")
.replace("{}", root.editDoc.name || "")
color: theme.textSecondary
font.pixelSize: 14
wrapMode: Text.WordWrap
}
// --- Footer
Row {
anchors.right: parent.right
spacing: 8
SettingsButton {
text: root.t("Cancel")
btnHeight: 38
minWidth: 0
enabled: !editPanel.busy
onClicked: QbzMyQbz.editClose()
}
QbzPrimaryButton {
label: editPanel.isDelete ? root.t("Delete") : root.t("Save")
btnHeight: 38
destructive: editPanel.isDelete
btnEnabled: editPanel.canSubmit
onClicked: {
if (editPanel.isRename)
QbzMyQbz.editSubmitRename(editPanel.draftName)
else if (editPanel.isDescription)
QbzMyQbz.editSubmitDescription(editPanel.draftDescription)
else
QbzMyQbz.editConfirmDelete()
}
}
}
}
}
}
// ================================================================
// PANEL 3 — DJ-mix "Random queue" (MyQbzMixModal.slint)
// ================================================================
Item {
id: mixPanel
anchors.fill: parent
visible: root.mixOpen
readonly property bool loading: root.mixDoc.loading === true
readonly property bool busy: root.mixDoc.busy === true
readonly property var sizeOptions: root.mixDoc.sizeOptions || []
readonly property int selectedIndex: root.mixDoc.selectedIndex || 0
readonly property int selectedSize: root.mixDoc.selectedSize || 0
readonly property bool selectedIsAll: root.mixDoc.selectedIsAll === true
readonly property int uniqueCount: root.mixDoc.uniqueCount || 0
// :35-39
readonly property bool canShuffle: !mixPanel.loading && !mixPanel.busy
&& mixPanel.sizeOptions.length > 0 && mixPanel.selectedSize > 0
// The slider travels over option INDICES, not song counts; a single
// option (only "All (N)") renders a degenerate 0..0 track (:42).
readonly property int sliderMax: Math.max(0, mixPanel.sizeOptions.length - 1)
readonly property string sizeLabel: mixPanel.selectedIsAll
? root.t("All ({})").replace("{}", mixPanel.selectedSize)
: String(mixPanel.selectedSize)
Rectangle {
anchors.fill: parent
radius: theme.radiusMd
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: QbzMyQbz.mixClose()
}
}
Rectangle {
width: Math.min(root.width - 80, 440)
height: mixCol.implicitHeight + 48
x: Math.round((root.width - width) / 2)
y: Math.round((root.height - height) / 2)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
MouseArea { anchors.fill: parent }
Column {
id: mixCol
x: 24
y: 24
width: parent.width - 48
spacing: 18
Text {
width: parent.width
text: root.t("Random queue")
color: theme.textPrimary
font.pixelSize: 18
font.weight: theme.weightBold
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.t("Create a random queue with songs from this collection. How many songs do you want?")
color: theme.textSecondary
font.pixelSize: 14
wrapMode: Text.WordWrap
}
// --- Loading (:93-106): left-aligned, 8px spacing.
Row {
visible: mixPanel.loading
height: visible ? 18 : 0
spacing: 8
QbzSpinner {
anchors.verticalCenter: parent.verticalCenter
size: 18
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.t("Loading…")
color: theme.textSecondary
font.pixelSize: 14
verticalAlignment: Text.AlignVCenter
}
}
// --- Size field (:109-154)
Column {
visible: !mixPanel.loading && mixPanel.sizeOptions.length > 0
width: parent.width
spacing: 8
Text {
text: root.t("Number of songs")
color: theme.textPrimary
font.pixelSize: 13
font.weight: theme.weightMedium
}
Row {
width: parent.width
spacing: 12
Text {
id: sizeValue
anchors.verticalCenter: parent.verticalCenter
width: Math.max(64, implicitWidth)
text: mixPanel.sizeLabel
color: theme.accent
font.pixelSize: 15
font.weight: theme.weightSemibold
verticalAlignment: Text.AlignVCenter
}
QbzSlider {
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, parent.width - sizeValue.width - 12)
minimum: 0
maximum: mixPanel.sliderMax
value: mixPanel.selectedIndex
onChanged: function (v) { QbzMyQbz.mixSetIndex(v) }
}
}
Text {
width: parent.width
text: root.t("{} songs available").replace("{}", mixPanel.uniqueCount)
color: theme.textMuted
font.pixelSize: 12
}
}
// --- Footer: spacing 12 here, NOT 8 (:159).
Row {
anchors.right: parent.right
spacing: 12
SettingsButton {
text: root.t("Cancel")
btnHeight: 38
minWidth: 0
enabled: !mixPanel.busy
onClicked: QbzMyQbz.mixClose()
}
QbzPrimaryButton {
label: root.t("Add to queue")
btnHeight: 38
btnEnabled: mixPanel.canShuffle
onClicked: QbzMyQbz.mixShuffle()
}
}
}
}
}
}
@@ -73,4 +73,14 @@ Popup {
var g = sourceItem.mapToItem(null, sourceItem.width - width, sourceItem.height + 4)
_place(g.x, g.y, sourceItem.Window.window)
}
/// Button menu: below the trigger, LEFT edges aligned. The mirror of
/// openBelowRight, for triggers that sit at the LEFT end of their row (the
/// MyQBZ hero overflow and the Disco builder's per-row type override), where
/// a right-aligned panel would hang off the trigger to the left and, on a
/// narrow window, get clamped by _place into visibly the wrong place.
function openBelowLeft(sourceItem) {
var g = sourceItem.mapToItem(null, 0, sourceItem.height + 4)
_place(g.x, g.y, sourceItem.Window.window)
}
}
+36 -4
View File
@@ -29,7 +29,14 @@ Rectangle {
property string text: ""
property string placeholder: ""
property bool isPassword: false
/// Fired on focus-loss AND on Enter — the "the value settled" signal.
signal committed(string value)
/// Fired on ENTER ONLY. A modal that submits on Enter must NOT listen to
/// `committed`: closing the modal removes focus, which fires `committed`
/// again and submits the same value a second time (a rename would run
/// twice, a create would make two collections). Emitted BEFORE `committed`
/// so a handler that closes the modal wins the race.
signal accepted(string value)
// --- search arm -------------------------------------------------------
/// Leading magnifier + live `edited` + trailing X.
@@ -56,9 +63,31 @@ Rectangle {
height: (searchMode || expandable) ? collapsedSize : 34
color: "transparent"
/// Set by `clearSearch()`, released the moment the CALLER publishes anything
/// into `text`. While it is set, the focus-loss re-seed Binding below is
/// suppressed.
///
/// This is what replaces the `root.text = ""` this function used to do. That
/// assignment was a JS write to a property the call site normally holds a
/// BINDING on (`text: root.doc.search`, `text: root.query`, …), so the first
/// click on the × destroyed the binding PERMANENTLY: Rust could never
/// re-seed the field again, and the re-seed Binding below then wrote the
/// now-frozen "" back into the input on every later focus loss. The X in
/// MyQBZ's grid + detail toolbars, DiscoverBrowse and PlaylistBrowse all
/// rode that. Clearing is now purely local — the input goes empty and
/// `edited("")` tells the owner, which republishes "" through the binding
/// that is still intact.
///
/// The latch is needed because the republish is not synchronous: between the
/// click and Rust's answer `root.text` still holds the OLD query, and a
/// focus loss in that window (expandable's `closeSearch()` drops focus by
/// design) would put it straight back.
property bool _cleared: false
onTextChanged: root._cleared = false
function clearSearch() {
input.text = ""
root.text = ""
root._cleared = true
root.edited("")
}
function closeSearch() {
@@ -151,7 +180,7 @@ Rectangle {
selectByMouse: true
echoMode: root.isPassword ? TextInput.Password : TextInput.Normal
text: root.text
onAccepted: root.committed(text)
onAccepted: { root.accepted(text); root.committed(text) }
onActiveFocusChanged: if (!activeFocus) root.committed(text)
onTextEdited: root.edited(text)
Keys.onEscapePressed: function (event) {
@@ -163,12 +192,15 @@ Rectangle {
}
}
// External republish (e.g. Reset) re-seeds the field while
// it is not being edited.
// it is not being edited. `!_cleared` holds it off for the
// frames between the × and the owner's republish, when
// `root.text` is still the query the user just cleared —
// see `_cleared`.
Binding {
target: input
property: "text"
value: root.text
when: !input.activeFocus
when: !input.activeFocus && !root._cleared
}
}
Text {
@@ -0,0 +1,53 @@
// QbzLoadingDots — the three-dot "still resolving" indicator
// (primitives/LoadingDots.slint). Mounted in the MyQBZ detail row's 160px
// quality cell while the badge has not resolved yet.
//
// A CHEAPER APPROXIMATION, on purpose. Slint runs a per-dot triangle wave off
// `animation-tick()` — phase offsets 0 / 0.18 / 0.36 over 900ms,
// `opacity: 0.25 + 0.55 * tri` (so 0.25 … 0.80, LoadingDots.slint:15-20).
// That is a per-DELEGATE animation, and in a windowed list of hundreds of rows
// it is exactly what the port's perf rule forbids. So the phase comes IN as an
// int 0..2 and the HOST owns one `Timer { interval: 300; repeat: true }` for
// the whole view, gated `<any row resolving> && visible && windowShowing`
// (PlaylistView.qml:156-159 + :173-179 — freeze on not-visible or minimized,
// NEVER on lost focus: a tiling desktop keeps windows visible and unfocused).
//
// The dot row CENTRES itself in whatever host it is given, because the .slint
// component inherits Rectangle and therefore fills its cell and centres its
// layout (:26-35) — it is not left-aligned at x=0.
//
// Slint's optional `label` arm (:36-41) is unused by MyQBZ and not ported.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
/// 0..2 — which dot is lit. Cycled by ONE Timer in the host view.
property int phase: 0
QbzTheme { id: theme }
implicitWidth: 3 * 8 + 2 * 8
implicitHeight: 8
Row {
anchors.centerIn: parent
spacing: 8
Repeater {
// A STATIC model (3), so the delegates are built once and only
// their opacity binding re-evaluates.
model: 3
delegate: Rectangle {
required property int index
width: 8
height: 8
radius: 4
color: theme.textSecondary
opacity: root.phase === index ? 0.8 : 0.25
}
}
}
}
@@ -1,23 +1,43 @@
// Bulk action bar (primitives/MultiSelectBar.slint, mounted by
// LocalLibraryView.slint:1239 for Albums and :1406 for Tracks).
// Bulk action bar — the floating "N selected" strip with one icon button per
// bulk action (primitives/MultiSelectBar.slint).
//
// PROMOTED from views/local/LocalMultiSelectBar.qml, unchanged except for the
// import depth. The Slint component it ports was already shared — Local Library
// mounts it for Albums (LocalLibraryView.slint:1239) and Tracks (:1406), and
// the MyQBZ detail page mounts the same primitive (:1253) — so the "Local"
// prefix was a naming accident, not a scoping one. The old
// views/local/LocalMultiSelectBar.qml is GONE — this file is the only copy.
// Call sites: views/local/LocalAlbumsTab.qml, views/local/LocalTracksTab.qml,
// views/myqbz/MyQbzDetailView.qml.
//
// 44px tall, radius 8, surface-elevated: "N selected" on the left, then one
// 34x30 icon button per action — dimmed to 0.4 and disarmed while the action
// needs a selection and there is none, red-bordered when it is a danger
// action. ADR-008: bordered/hover buttons, not pills.
//
// `width` has NO default: every call site anchors or sizes it (the MyQBZ detail
// page uses the .slint's deliberately asymmetric `x: 18` / `width: parent.width
// - 40`). The label Text sizes itself from `parent.width - actionRow.width`, so
// a zero width silently collapses the label rather than the buttons.
//
// The Slint puts the action label in the shared tooltip bubble; the Qt port
// has no tooltip channel yet, so the label rides Qt's own ToolTip.
// has no tooltip channel into this component, so the label rides Qt's own
// ToolTip. (controls/QbzTooltip.qml is the shell's single hover-tooltip
// overlay, but it is reached by calling showRight()/showAbove() on the
// AppShell instance, which a recycled delegate deep inside a view cannot name.)
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../../theme"
import "../theme"
Rectangle {
id: root
/// [{ id, label, icon, danger, needsSelection }]
/// [{ id, label, icon, danger, needsSelection }] — `id` is what `action`
/// publishes; `label` is the tooltip text and must arrive ALREADY
/// translated (this component calls no `tr`); `icon` must exist in the
/// "warning", "textPrimary" and "secondary" tint dirs.
property var actions: []
property int selectedCount: 0
signal action(string id)
@@ -0,0 +1,85 @@
// QbzPrimaryButton — the accent-filled labelled button
// (primitives/QbzPrimaryButton.slint). The port had no such control outside
// LoginScreen.qml's inline copy; MyQBZ has five call sites (the Add modal's
// "Create & Add", Create's "+ New Mixtape/Collection", Edit's Save/Delete,
// Mix's "Add to queue", the builder footer's "Create Collection").
// Promoting LoginScreen's copy onto this file is follow-up debt, not part of
// this migration.
//
// THE API IS `btnEnabled` / `btnHeight`, NOT `enabled` / `height`:
// `enabled` shadows Item.enabled (which also disarms child MouseAreas
// through a different mechanism) and the call sites all pass an explicit
// height — 36 in the builder footer, 38 in all four modals, 48 default
// (QbzPrimaryButton.slint:15).
//
// Numbers, off the .slint: r8 (:16), opacity 0.5 when disabled (:17 — NOT
// the 0.4 SettingsButton uses), accent / accent-hover fill (:24) with a
// 150ms transition (:26), destructive = SOLID danger darkened 15% on hover
// (:22-23) — `Theme.danger-hover` is a translucent tint for danger-BG
// surfaces and would render the button pale (the rationale is spelled out at
// :18-21). Padding 20 per side so `implicitWidth = label + 40` (:31-34);
// label Typography.button (17px) semibold (:36-41).
//
// The label colour is `theme.accentGlyphColor`, not a hardcoded
// `Theme.accent-text`: the owner-approved measured on-accent selector
// (theme/QbzTheme.qml, "ON AN ACCENT FILL"). Do not "restore" #ffffff here.
//
// NOT ported (deliberate, both are port-wide gaps rather than MyQBZ ones):
// the .slint's FocusScope Enter/Space activation and its keyboard focus ring
// (:62-86). This port has no keyboard/shortcut layer at all — the whole tree
// carries two key handlers (QbzLineEdit.qml:157, HeaderBar.qml:551) — so
// wiring one here would be the only focusable button in the app.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
property string label: ""
/// NOT `enabled` — see the header.
property bool btnEnabled: true
property bool destructive: false
property int btnHeight: 48
signal clicked()
QbzTheme { id: theme }
// Height is pinned; the width follows the label so the button auto-sizes
// as a footer chip, and a call site that wants a full-width button
// assigns `width` (Item.width defaults to implicitWidth).
height: root.btnHeight
implicitWidth: lbl.implicitWidth + 40
radius: theme.radiusSm
opacity: root.btnEnabled ? 1.0 : 0.5
color: root.destructive
? ((btnArea.containsMouse && root.btnEnabled)
? Qt.darker(theme.danger, 1.15) : theme.danger)
: ((btnArea.containsMouse && root.btnEnabled)
? theme.accentHover : theme.accent)
Behavior on color { NumberAnimation { duration: 150 } }
Text {
id: lbl
anchors.centerIn: parent
width: Math.min(implicitWidth, root.width - 40)
text: root.label
color: theme.accentGlyphColor
font.pixelSize: theme.fontButton
font.weight: theme.weightSemibold
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
MouseArea {
id: btnArea
anchors.fill: parent
enabled: root.btnEnabled
hoverEnabled: root.btnEnabled
cursorShape: root.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.clicked()
}
}
@@ -0,0 +1,70 @@
// QbzRadioOption — one labelled radio row (the `KindRadio` component that
// AddToMixtapeModal.slint:28-60 and CreateMyQbzModal.slint:22-55 declare
// BYTE-IDENTICALLY, once each). The port has no radio control at all, so this
// is the first one; both MyQBZ modals use it and nothing else does yet.
//
// Geometry off the .slint: an 18x18 circle (r9) whose ring is 1.5px
// `text-muted` when unselected and vanishes when selected (the fill takes
// over), holding a 7x7 r3.5 dot; 6px to a `Typography.body` label. The whole
// row is the hit target and shows the pointer cursor (the .slint's root IS a
// TouchArea).
//
// The dot is `theme.accentGlyphColor`, not the .slint's literal `#ffffff`
// (:49 / :44) — the measured on-accent selector (theme/QbzTheme.qml, "ON AN
// ACCENT FILL"), the same divergence views/local/SelectCheck.qml already
// takes for its check glyph.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
property string label: ""
property bool selected: false
signal clicked()
QbzTheme { id: theme }
implicitWidth: row.implicitWidth
implicitHeight: Math.max(18, lbl.implicitHeight)
Row {
id: row
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 18
height: 18
radius: 9
border.width: root.selected ? 0 : 1.5
border.color: theme.textMuted
color: root.selected ? theme.accent : "transparent"
Rectangle {
visible: root.selected
anchors.centerIn: parent
width: 7
height: 7
radius: 3.5
color: theme.accentGlyphColor
}
}
Text {
id: lbl
anchors.verticalCenter: parent.verticalCenter
text: root.label
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
}
+132
View File
@@ -0,0 +1,132 @@
// Segmented icon toggle — an N-way "pick one" rendered as icon-only segments
// inside a small rounded well.
//
// PROMOTED from views/local/LocalSegToggle.qml. Its Local geometry survives
// UNCHANGED as the defaults; the cell metrics and the active/hover look are
// now properties so the MyQBZ well can be the reference's other set (see TWO
// METRIC SETS below).
// It was never local-library-specific: Slint has the same widget
// three times over (ViewToggle grid/list at LocalLibraryView.slint:358,
// ModeToggle flat/tree at :384, and MyQbzShared.slint:57-70's list/grid), as
// three separate components only because its ToggleButton cannot take a model.
// Here one `segments` model covers all of them.
//
// Call sites: views/myqbz/MyQbzGridView.qml (2 segments, list/grid),
// views/myqbz/MyQbzDetailView.qml (3 segments, list/grid/expanded, `width: 90`
// — see the sizing note below), views/local/LocalToolbar.qml (3 instances:
// albums grid/list, folders grid/list, folders flat/tree) and
// views/local/LocalFolderDetail.qml (grid/list). The old
// views/local/LocalSegToggle.qml is GONE — this file is the only copy.
//
// SIZING. `width` is a plain assignment, not a binding on the content, so a
// call site with more than two segments MUST widen it: with the DEFAULT metrics
// the segments are a centred Row of 26px cells with 2px gaps, so N segments
// need 28N - 2 px (2 -> 54, fits the 60 default; 3 -> 82, hence the detail
// view's 90). Too narrow does not wrap or elide — `clip: true` silently cuts
// the last segment off, and a segment you cannot see is a segment you cannot
// click.
//
// ── TWO METRIC SETS, AND WHY THE CELL LOOK IS PARAMETERISED ────────────────
// The DEFAULTS below are the Local Library set, unchanged: 26x24 r4 cells with
// 2px gaps (so 3px of dead well on each side), a 14px glyph, active fill
// surface-main and active tint `accent`, hover surface-hover. All four Local
// call sites (LocalToolbar.qml x3, LocalFolderDetail.qml) set NOTHING but
// `segments` / `mode` / `onSetMode`, so they keep exactly that.
//
// MyQBZ's toggle is a DIFFERENT widget in the reference and always was —
// `MyQbzShared.slint:49-71` (2 segments, 60x30) and
// `MixtapeDetailView.slint:1058-1084` (3 segments, 90x30) fill the well
// EDGE TO EDGE with `ToggleButton { sm: true }`, i.e. 30x30 cells, no gaps, a
// 15px glyph, and — the load-bearing part, spelled out at MyQbzShared.slint:47-48
// — active = `surface-hover`, NEVER accent (`ToggleButton.slint:19-20,24-28,32-33,38`:
// 30x30 at `sm`, active background surface-hover, hover surface-elevated, idle
// transparent, tint text-primary active / text-muted idle). So the two MyQBZ
// call sites pass that set explicitly. Nine properties at two call sites, in
// preference to a second near-identical component (TRACK-RULES §5).
//
// `segRadius` is the one number that is NOT the reference's: Slint's
// ToggleButton has border-radius 0 and relies on the WELL's `clip: true` to
// round the outer corners. A QML clip is a rectangular scissor and does not
// follow `radius`, so an edge-to-edge square cell would paint its own square
// corner over the well's curve. The curve therefore has to come from the cell,
// which also rounds the two INNER corners Slint leaves square — invisible at
// this contrast, since the active fill is `surfaceHover` = #10ffffff, a 6%
// white wash over the well's own surface-elevated.
//
// ADR-008: small-radius rectangle, never a pill.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
/// [{ id, icon }] — `id` is the value published through `setMode`, `icon`
/// is a QbzIcon name that must exist in BOTH the `activeTint` and the
/// `idleTint` directories. A missing tint variant renders nothing at all,
/// with nothing logged.
property var segments: []
property string mode: ""
signal setMode(string value)
// --- cell metrics. DEFAULTS = the Local Library set (see the header). ---
property int segWidth: 26
property int segHeight: 24
property int segRadius: 4
property int segSpacing: 2
property int glyphSize: 14
/// Fill of the SELECTED cell. Local: surface-main. MyQBZ/ToggleButton:
/// surface-hover — "active = surface-hover, NEVER accent"
/// (MyQbzShared.slint:47-48).
property color activeFill: theme.surfaceMain
/// Fill of a hovered, unselected cell. `ToggleButton.slint:28` uses
/// surface-elevated, which equals the WELL's own fill, i.e. the reference's
/// hover is deliberately a no-op inside a well — pass `theme.surfaceElevated`
/// to reproduce that, not a visible highlight.
property color hoverFill: theme.surfaceHover
/// QbzIcon tint names (theme/QbzIcon.qml's vocabulary). Local: accent /
/// secondary. ToggleButton.slint:38: text-primary / text-muted.
property string activeTint: "accent"
property string idleTint: "secondary"
QbzTheme { id: theme }
width: 60
height: 30
radius: 6
color: theme.surfaceElevated
clip: true
Row {
anchors.centerIn: parent
spacing: root.segSpacing
Repeater {
model: root.segments
delegate: Rectangle {
id: seg
required property var modelData
readonly property bool active: modelData.id === root.mode
width: root.segWidth
height: root.segHeight
radius: root.segRadius
color: active ? root.activeFill
: segArea.containsMouse ? root.hoverFill : "transparent"
QbzIcon {
name: seg.modelData.icon
width: root.glyphSize
height: root.glyphSize
anchors.centerIn: parent
tintName: seg.active ? root.activeTint : root.idleTint
}
MouseArea {
id: segArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.setMode(seg.modelData.id)
}
}
}
}
}
+26 -11
View File
@@ -20,20 +20,35 @@ Rectangle {
property int popupWidth: 0
property bool enabled: true
property bool searchable: false
// Bootstrap-style small variant (QbzSelect.slint:74-76). The default is the
// standard Settings size; TOOLBARS opt in with `sm: true` — 30px tall, r6,
// NO outline (it matches the favorites toolbar buttons), 12px label and a
// 14px chevron, and 40px narrower because toolbar selects were needlessly
// wide (QbzSelect.slint:86-88,102-105,118-119,126,147-148,299).
property bool sm: false
signal selected(int index)
QbzTheme { id: theme }
id: selectRoot
width: menuWidth
height: 34
radius: theme.radiusSm
border.width: 1
width: sm ? Math.max(0, menuWidth - 40) : menuWidth
height: sm ? 30 : 34
radius: sm ? 6 : theme.radiusSm
border.width: sm ? 0 : 1
border.color: theme.borderSubtle
color: selArea.containsMouse && enabled ? theme.surfaceHover : theme.surfaceElevated
opacity: enabled ? 1.0 : 0.4
readonly property int listWidth: Math.max(popupWidth, menuWidth)
// `width`, NOT `menuWidth`: QbzSelect.slint:89-91 resolves the open list as
// `max(popup-width, EFF-WIDTH)`, and eff-width is `menu-width - 40px` at
// `sm` (:88) — the same value it uses for the collapsed control. Reading the
// RAW menuWidth here made every toolbar dropdown's PANEL 40px wider than the
// reference's (MyQBZ sort 180 vs 140, kind filter 200 vs 160, detail sort
// 170 vs 130, and the Local Library toolbar / LabelReleases selects with it).
// The three `popupWidth` callers (AudioSettings 480, CastPicker 260,
// LyricsControlsFlyout 178) are all non-`sm`, where width === menuWidth and
// popupWidth wins anyway, so none of them moves.
readonly property int listWidth: Math.max(popupWidth, selectRoot.width)
readonly property int rowHeight: 32
readonly property int headerHeight: 24
readonly property int searchHeight: searchable ? 42 : 0
@@ -58,8 +73,8 @@ Rectangle {
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 10
anchors.leftMargin: selectRoot.sm ? 10 : 12
anchors.rightMargin: selectRoot.sm ? 8 : 10
spacing: 8
Text {
width: parent.width - 16 - 8 - badgeSlot.width - (badgeSlot.visible ? 8 : 0)
@@ -67,7 +82,7 @@ Rectangle {
text: selectRoot.currentIndex >= 0 && selectRoot.currentIndex < selectRoot.options.length
? selectRoot.optLabel(selectRoot.currentIndex) : ""
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.pixelSize: selectRoot.sm ? 12 : theme.fontBody
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
@@ -99,8 +114,8 @@ Rectangle {
}
QbzIcon {
name: "chevron-down"
width: 16
height: 16
width: selectRoot.sm ? 14 : 16
height: selectRoot.sm ? 14 : 16
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
@@ -247,7 +262,7 @@ Rectangle {
text: optRow.label
color: optRow.index === selectRoot.currentIndex
? theme.accent : theme.textSecondary
font.pixelSize: theme.fontBody
font.pixelSize: selectRoot.sm ? 12 : theme.fontBody
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
@@ -0,0 +1,81 @@
// QbzTextArea — the port's first MULTILINE input. Slint uses std-widgets
// `TextEdit` (MyQbzEditModal.slint:90-99, 96px tall, word-wrap); QML's
// QtQuick.Controls TextArea would bring its own styled background, so this is
// the same Rectangle-plus-input recipe controls/QbzLineEdit.qml uses for the
// single-line case: r8 surface-elevated box, 1px border that turns accent on
// focus, 12/8 padding, a Flickable so a description longer than the box
// scrolls instead of clipping silently.
//
// One call site today (the Edit modal's "Edit description" body). The API is
// deliberately the same shape as QbzLineEdit's: `text` in, `edited(value)`
// out, and the external re-seed goes through a `Binding { when: !activeFocus }`
// so a republish while the user is typing cannot fight the caret.
//
// No Enter-submit: a newline is legitimate content here (MyQbzEditModal has
// no `accepted` handler on this body either).
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
property string text: ""
property string placeholder: ""
/// Exposed so a call site can pass TextEdit.WordWrap / .NoWrap explicitly;
/// the .slint body is `wrap: word-wrap`.
property int wrapMode: TextEdit.WordWrap
signal edited(string value)
QbzTheme { id: theme }
width: 240
height: 96
radius: theme.radiusSm
color: theme.surfaceElevated
border.width: 1
border.color: input.activeFocus ? theme.accent : theme.borderSubtle
Flickable {
id: flick
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
anchors.topMargin: 8
anchors.bottomMargin: 8
clip: true
contentWidth: width
contentHeight: input.implicitHeight
boundsBehavior: Flickable.StopAtBounds
TextEdit {
id: input
width: flick.width
text: root.text
color: theme.textPrimary
font.pixelSize: theme.fontBody
wrapMode: root.wrapMode
selectByMouse: true
onTextChanged: if (activeFocus) root.edited(text)
// External republish re-seeds the field while it is not being
// edited (the QbzLineEdit.qml:167-172 idiom).
Binding {
target: input
property: "text"
value: root.text
when: !input.activeFocus
}
Text {
visible: input.text === "" && root.placeholder !== ""
width: parent.width
text: root.placeholder
color: theme.textMuted
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
}
}
}
}
+167
View File
@@ -0,0 +1,167 @@
// QbzToast — the app's in-app toast host (primitives/Toast.slint), mounted
// ONCE in AppShell.qml as a sibling of the content frame. Until this file the
// port had NO toast seam at all: TrackRow.qml:597-600, LabelView.qml:25,
// LyricsPanel.qml:16, PlayerBar.qml:515-517, NowPlayingBarSmall.qml:461-463
// and local_bulk.rs:190-193 each document the same gap and fall back to
// silence. Silence is not 1:1 — "Already in {name}" is the ONLY signal that a
// duplicate add did nothing, and MyQBZ alone routes 26 distinct messages
// through crate::toast.
//
// CONTRACT. Rust publishes `QbzShell.toastJson`
// (`{ seq, kind, message, persistent }`, ToastDoc): ONE toast at a time, a new
// one replaces the current, and Rust has ALREADY applied the `in_app_toasts`
// settings gate (errors always pass) — this file must not re-read the setting.
// The auto-hide timer lives HERE, keyed off `seq`, so Rust never owns a timer
// nor needs a hide round-trip: success/info 3000ms, warning 4000, error 5000,
// buffering persistent (qbz-slint-common/src/toast.rs:31-40).
//
// The component is ALWAYS in the tree (never `if`-gated) so the entry
// animation runs; the close button's MouseArea is live only while a toast is
// showing, so the transparent overlay never eats a click meant for the content
// underneath. The root itself carries no MouseArea at all.
//
// Geometry (Toast.slint): bubble 44px tall, width = content capped at
// `parent.width - 80`, horizontally centred, `y = parent.height - height -
// (showing ? 100 : 84)` so it slides up the last 16px, 200ms ease-out on both
// y and opacity, r8 surface-elevated, padding 16 left / 8 right, row spacing
// 12, 20px kind icon, 14px message elided to one line, 24x24 r4 close with a
// 16px glyph.
//
// The .slint's `drop-shadow-blur: 16px / #00000066` (:55-56) is DROPPED: Qt's
// shadow effects render nothing on this port's software path (the same reason
// NavFlyout.qml:30-33 and every modal drop it). The 44px elevated fill on a
// dark scene carries the separation.
//
// ASSET + TINT PREREQUISITE (wiring items W21/W22): `circle-check-big`,
// `circle-alert` and `triangle-alert` are NOT in qml/assets/icons/* yet, and
// icon_tint_qt.rs's TOKENS has no `success` / `danger` tint. QbzIcon takes a
// tint NAME, never a colour, so both halves must land or the success/error
// glyphs render NOTHING with nothing logged.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
QbzTheme { id: theme }
// Guarded parse — a raw JSON.parse inside a binding throws on the
// pre-publish frame and takes the host down with it
// (PlaylistView.qml:55-61).
readonly property var doc: {
try {
return JSON.parse(QbzShell.toastJson)
} catch (e) {
return ({})
}
}
readonly property int seq: root.doc.seq || 0
readonly property string kind: root.doc.kind || "info"
readonly property string message: root.doc.message || ""
readonly property bool persistent: root.doc.persistent === true
property bool showing: false
readonly property int autoHideMs: root.kind === "error" ? 5000
: root.kind === "warning" ? 4000 : 3000
readonly property string iconName: root.kind === "success" ? "circle-check-big"
: root.kind === "error" ? "circle-alert"
: root.kind === "warning" ? "triangle-alert"
: root.kind === "buffering" ? "loader-circle"
: "info"
readonly property string iconTint: root.kind === "success" ? "success"
: root.kind === "error" ? "danger"
: root.kind === "warning" ? "warning"
: "accent"
// A new sequence number IS the show event (a repeated message with the
// same text must still re-show, so the trigger cannot be the text).
onSeqChanged: {
if (root.seq <= 0 || root.message === "")
return
root.showing = true
hideTimer.stop()
if (!root.persistent)
hideTimer.restart()
}
Timer {
id: hideTimer
interval: root.autoHideMs
repeat: false
onTriggered: root.showing = false
}
Rectangle {
id: bubble
// 16 pad + 20 icon + 12 gap + message + 12 gap + 24 close + 8 pad.
readonly property real chromeWidth: 16 + 20 + 12 + 12 + 24 + 8
width: Math.min(bubble.chromeWidth + Math.ceil(msg.implicitWidth),
Math.max(0, root.width - 80))
height: 44
x: Math.round((root.width - width) / 2)
y: root.height - height - (root.showing ? 100 : 84)
opacity: root.showing ? 1.0 : 0.0
Behavior on y { NumberAnimation { duration: 200; easing.type: Easing.OutQuad } }
Behavior on opacity { NumberAnimation { duration: 200; easing.type: Easing.OutQuad } }
radius: 8
color: theme.surfaceElevated
Row {
anchors.fill: parent
anchors.leftMargin: 16
anchors.rightMargin: 8
spacing: 12
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
name: root.iconName
width: 20
height: 20
tintName: root.iconTint
}
Text {
id: msg
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, bubble.width - bubble.chromeWidth)
text: root.message
color: theme.textPrimary
font.pixelSize: 14
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 24
height: 24
radius: 4
color: closeArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 16
height: 16
tintName: closeArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: closeArea
anchors.fill: parent
// Live ONLY while a toast is showing — the hidden overlay
// must not capture clicks (Toast.slint:106-108).
enabled: root.showing
hoverEnabled: root.showing
cursorShape: Qt.PointingHandCursor
onClicked: {
hideTimer.stop()
root.showing = false
}
}
}
}
}
}
@@ -0,0 +1,103 @@
// QbzToolButton — the MyQBZ detail toolbar's `ToolBtn`
// (myqbz/MixtapeDetailView.slint:85-151): a 30px bordered chip that is
// icon-only, icon+label, or icon+label+badge, with TWO distinct active looks.
//
// Why not controls/BrowseGenreButton.qml: that one is hard-bound to
// `QbzBridge.genreFilterJson` and builds its own label out of it, so a generic
// label/badge/active-mode contract cannot be a parameter of it.
//
// THE TWO ACTIVE MODES are the whole point, and they are not interchangeable:
// * `fillActive: true` — active paints an ACCENT FILL (the Filter button,
// which is labelled and carries the filter count).
// * `fillActive: false` — active paints an accent BORDER on the normal
// elevated fill (the icon-only Select / Reset buttons). Never a fill.
//
// Numbers: height 30, r6, width = the row's own padded width (:97-99, so the
// padding centres the content rather than an absolute x offset), 1px border;
// padding 8/side icon-only and 10/side labelled (:108-109); row spacing 6;
// glyph 15x15; label 12px; badge 16x16 r8 with 10px semibold text.
//
// Where the .slint hardcodes `#ffffff` on the accent fill (:120, :127, :137)
// this uses `theme.accentGlyphTint` / `theme.accentGlyphColor` — the measured
// on-accent selector (theme/QbzTheme.qml, "ON AN ACCENT FILL"). The badge's
// non-filled text stays `accentText` because there it sits on the accent disc,
// which is what that token is for.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
property string name: ""
property string label: ""
/// > 0 renders the trailing count disc.
property int badge: 0
property bool active: false
/// Active = accent FILL (labelled buttons) vs accent BORDER (icon-only).
property bool fillActive: false
signal clicked()
QbzTheme { id: theme }
readonly property bool filled: root.active && root.fillActive
readonly property int padH: root.label === "" ? 8 : 10
height: 30
implicitWidth: 2 * root.padH + row.implicitWidth
radius: 6
color: root.filled ? theme.accent
: (toolArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated)
border.width: 1
border.color: (root.active && !root.fillActive) ? theme.accent : theme.borderSubtle
Row {
id: row
anchors.centerIn: parent
spacing: 6
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
name: root.name
width: 15
height: 15
tintName: root.filled ? theme.accentGlyphTint
: (root.active ? "textPrimary" : "muted")
}
Text {
visible: root.label !== ""
anchors.verticalCenter: parent.verticalCenter
text: root.label
color: root.filled ? theme.accentGlyphColor
: (root.active ? theme.textPrimary : theme.textSecondary)
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
}
Rectangle {
visible: root.badge > 0
anchors.verticalCenter: parent.verticalCenter
width: root.badge > 0 ? 16 : 0
height: 16
radius: 8
color: root.filled ? theme.accentGlyphColor : theme.accent
Text {
anchors.centerIn: parent
text: root.badge
color: root.filled ? theme.accent : theme.accentText
font.pixelSize: 10
font.weight: theme.weightSemibold
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
MouseArea {
id: toolArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
}
+19 -2
View File
@@ -10,15 +10,24 @@ import "../theme"
Rectangle {
property string text: ""
property string iconName: ""
// Trailing chevron/glyph AFTER the label (the Slint settings rows that open
// a sub-view: ContentFilteringSettings.slint's Manage affordance). "" = none.
property string trailingIconName: ""
property bool danger: false
property bool busy: false
property bool enabled: true
// The two metrics the MyQBZ modal footers and the Disco builder need. The
// Slint's SecondaryButton is 34px/min-160 in Settings and 38px/hug in a
// modal footer, which is one control with two call-site numbers — not two
// components (TRACK-RULES §5). minWidth: 0 = hug the content.
property int btnHeight: 34
property int minWidth: 160
signal clicked()
QbzTheme { id: theme }
width: Math.max(iconName !== "" && text === "" ? 34 : 160, row.implicitWidth + 24)
height: 34
width: Math.max(iconName !== "" && text === "" ? 34 : minWidth, row.implicitWidth + 24)
height: btnHeight
radius: theme.radiusSm
border.width: 1
border.color: danger ? theme.danger : theme.borderSubtle
@@ -45,6 +54,14 @@ Rectangle {
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
}
QbzIcon {
visible: parent.parent.trailingIconName !== ""
name: parent.parent.trailingIconName
width: 15
height: 15
anchors.verticalCenter: parent.verticalCenter
tintName: parent.parent.danger ? "favorite" : "secondary"
}
}
MouseArea {
id: btnArea
+41 -6
View File
@@ -25,7 +25,9 @@
// Signals: playRequested() (per-site play: album-scoped, playlist-scoped
// or plain), enqueueRequested(mode) ("next"|"later"|"queue"),
// removeRequested(), bodyDragStarted(index) (fired BEFORE the shared
// dragStart — the #589 reorder pre-hook).
// dragStart — the #589 reorder pre-hook), mixtapeRequested() (the host
// builds the MyQBZ AddItem payload), goToRequested(kind) (only when
// `routeGoToExternally` is set).
// Favorite toggling, Go-to-artist/album, Share and Track info are
// identical on every site — handled internally. The row BODY is the drag
// source (6px threshold, ghost + sidebar drops in main.rs) and its RIGHT
@@ -110,20 +112,40 @@ Rectangle {
// matching `menuAction` branch when the invokable lands; the entry then
// appears in its .slint-correct slot. Why each is missing:
// radio no radio invokable on any Qt bridge object
// mixtape QbzLocal.albumAddToMixtape is LOCAL-album only
// playlistAdd no picker modal, no by-id add (only the sidebar drag)
// songlink needs the ISRC -> Deezer -> Odesli round-trip (backend)
// offlineCache no per-track cache bridge (the download cell is a stub)
readonly property bool hasRadioSeam: false
readonly property bool hasMixtapeSeam: false
readonly property bool hasPlaylistAddSeam: false
readonly property bool hasSonglinkSeam: false
readonly property bool hasOfflineCacheSeam: false
// LIVE since the MyQBZ domain landed: QbzMyQbzAdd.open() takes a JSON
// array of AddItem, so the row only has to ASK — it does not know its own
// itemType/source, which is why the payload is built by the host (see
// `mixtapeRequested` below). Not `readonly`: a host whose ids are not
// Qobuz/local mixtape-addressable can turn it back off.
property bool hasMixtapeSeam: true
signal playRequested()
signal enqueueRequested(string mode)
signal removeRequested()
signal bodyDragStarted(int index)
/// "Add to mixtape" — the HOST answers, because only the host knows the
/// row's `itemType` / `source` (a MyQBZ AddItem needs both) and building
/// the array here would hardcode `("track", "qobuz")` for every surface.
signal mixtapeRequested()
// --- Go-to routing: internal by default, host-routed on request -------
// The go-to actions (menu AND the artist-column link) navigate through
// QbzArtist.openArtist / QbzAlbum.openAlbum here, which is right on every
// catalog surface. It is WRONG where the row is a child of another entity
// whose host must resolve the target — MyQBZ's expanded detail rows, where
// Slint routes both paths through the SAME shared handler
// (`media-action("artist", id, "open")`, TrackRow.slint:530). Flipping
// `routeGoToExternally` hands both sites to the host as
// `goToRequested(kind)`; it defaults false, so no existing call site moves.
property bool routeGoToExternally: false
signal goToRequested(string kind) // "artist" | "album"
// --- THE heart state (a real QML property, never `item.isFavorite`) ---
// The row used to flip its glyph with `root.item.isFavorite = !…`. That is
@@ -400,7 +422,13 @@ Rectangle {
enabled: root.artistLink && !!root.item.artistId
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: QbzArtist.openArtist(root.item.artistId)
// Same routing as the menu's "Go to artist" — Slint drives
// both through one handler, so gating only the menu would
// leave the LINK navigating to the track's own artist.
onClicked: {
if (root.routeGoToExternally) root.goToRequested("artist")
else QbzArtist.openArtist(root.item.artistId)
}
}
}
}
@@ -585,9 +613,16 @@ Rectangle {
else if (a === "next") root.enqueueRequested("next")
else if (a === "later") root.enqueueRequested("later")
else if (a === "queue") root.enqueueRequested("queue")
else if (a === "go-artist") QbzArtist.openArtist(root.item.artistId)
else if (a === "go-album") QbzAlbum.openAlbum(root.item.albumId)
else if (a === "go-artist") {
if (root.routeGoToExternally) root.goToRequested("artist")
else QbzArtist.openArtist(root.item.artistId)
}
else if (a === "go-album") {
if (root.routeGoToExternally) root.goToRequested("album")
else QbzAlbum.openAlbum(root.item.albumId)
}
else if (a === "favorite") root.toggleFavorite()
else if (a === "mixtape") root.mixtapeRequested()
else if (a === "remove") root.removeRequested()
else if (a === "share-qobuz") root.copyToClipboard(
"https://open.qobuz.com/track/" + (root.item.id || ""))
+95 -8
View File
@@ -158,6 +158,7 @@ Rectangle {
clip: true
Loader {
id: viewLoader
anchors.fill: parent
source: QbzShell.currentView === "home" ? "../views/HomeView.qml"
: QbzShell.currentView === "library" ? "../views/LibraryView.qml"
@@ -181,7 +182,44 @@ Rectangle {
// mix tile navigated to a view nobody mounted and the content
// pane simply went blank (back recovered, which is why it read
// as "nothing happens" rather than a crash).
: QbzShell.currentView === "mix" ? "../views/MixView.qml" : ""
: QbzShell.currentView === "mix" ? "../views/MixView.qml"
// MyQBZ. Mixtapes and Collections are ONE file — the two
// pages differ only in their filter row and their empty
// state, so MyQbzGridView carries a `kind` discriminator
// (see the Binding below, which is how it gets set).
: QbzShell.currentView === "mixtapes"
|| QbzShell.currentView === "collections" ? "../views/myqbz/MyQbzGridView.qml"
: QbzShell.currentView === "mixtapedetail" ? "../views/myqbz/MyQbzDetailView.qml"
: QbzShell.currentView === "discobuilder" ? "../views/myqbz/DiscoBuilderView.qml"
// Settings > Blacklist > Manage. Not reachable from the
// sidebar — blacklist_qt::open_manager records the route.
: QbzShell.currentView === "blacklist" ? "../views/BlacklistManagerView.qml" : ""
}
// MyQbzGridView serves BOTH the "mixtapes" and "collections" routes
// and needs to know which. A Loader cannot pass properties through a
// declarative `source:` ternary, and the view's own default is
// "mixtape" — so without this the Collections route would render the
// Mixtapes page, permanently, with nothing logged.
//
// A Binding rather than `onLoaded`/`setSource`: it applies the moment
// `item` exists (the Loader creates it synchronously, so still before
// the first render pass), it re-applies if the route flips
// mixtapes <-> collections while the same instance is mounted, and it
// keeps the whole router declarative. Same shape as the seeded-field
// pattern at controls/QbzLineEdit.qml:174-179.
//
// RestoreNone: the target is DESTROYED on unload, and the default
// RestoreBindingOrValue would try to write the old value back into a
// dying object.
Binding {
target: viewLoader.item
property: "kind"
value: QbzShell.currentView === "collections" ? "collection" : "mixtape"
when: viewLoader.item !== null
&& (QbzShell.currentView === "mixtapes"
|| QbzShell.currentView === "collections")
restoreMode: Binding.RestoreNone
}
}
@@ -220,13 +258,26 @@ Rectangle {
// touches the frame.
//
// Mechanism: four small Canvas nubs, each filling its corner square with
// the shell colour and punching the quarter-disc back out. Canvas is this
// port's rounding primitive for exactly this reason
// (theme/RoundedImage.qml, cards/PlaylistCollage.qml): ShaderEffect /
// OpacityMask render NOTHING on the software path, and `layer.enabled` +
// a rounded mask would cost an FBO the size of the whole content pane
// every frame. Cost here is 4 x 12x12 px rastered once, repainted only
// when the theme colour or the radius changes.
// the shell colour and punching the quarter-disc back out.
//
// DOCTRINE CORRECTION (2026-07-29). This paragraph used to justify the
// Canvas by claiming "ShaderEffect / OpacityMask render NOTHING on the
// software path, and `layer.enabled` would cost an FBO every frame".
// Both halves are FALSE as stated: effects need shaders, and this port
// runs on the GPU (OpenGL RHI, measured via QSG_INFO 2026-07-29) — the
// "renders nothing" note came from an offscreen session, which forces the
// software renderer by definition. A `layer` FBO is also rendered once and
// CACHED for static content, not re-rendered per frame. Where a software
// path is genuinely possible, detect it with `GraphicsInfo.api` rather
// than assuming it (theme/RoundedImage.qml does exactly that and is the
// canonical statement of this rule — read its "HOW THE ROUNDING IS DONE"
// header before touching any masking code).
//
// The Canvas here is still the right call, but on its own merits, not that
// one: the nubs are 4 x 12x12 px rastered ONCE and repainted only when the
// theme colour or the radius changes, whereas masking the frame would mean
// an FBO the size of the whole content pane, re-rendered on every window
// resize and invalidated by anything animating inside the pane.
//
// The fill is the shell base (`root.color`) because that is literally
// what shows through the bezel: the 8px gutter, the HeaderBar above
@@ -468,6 +519,42 @@ Rectangle {
}
}
// --- MyQBZ global overlays + the shared toast host ---------------------
// Three window-level overlays, mounted ONCE here because each is reachable
// from anywhere and must outlive the view that triggered it:
//
// AddToMixtapeModal — the "Add to Mixtape/Collection" picker, opened from
// a TrackRow menu, an album/playlist page, a Local Library bulk bar or
// the now-playing bar. The view that opened it is often unmounted by
// the time the user picks a target.
// MyQbzModals — Create / Edit (rename · description · delete) / DJ
// Mix, three sibling panels in one file (spec 01 §10/§11). Delete in
// particular navigates away from the page that raised it.
// QbzToast — the ONE in-app toast host (toast_qt.rs publishes
// onto QbzShell.toastJson; the auto-hide timer lives in the QML).
//
// NO `z` on any of them, and that is the point. This file's convention is
// DECLARATION ORDER (see the note at ArtPreviewOverlay below: "LAST child =
// above every surface"), and the bezel-corner note at :200-264 explains why
// `z` is the wrong tool out here — ADR-009 pins in-pane modals at z >= 3000
// with no ceiling, so any number picked here loses to the next one added.
// Position in the chain is byte-equivalent to Slint, where `Toast { }` sits
// at AppShell.slint:950: after DragGhost, before TooltipOverlay and
// ArtPreviewOverlay. Covering the content pane's rounded corners is CORRECT
// for a window-level overlay — a window-wide scrim covers them too.
//
// All three self-gate on their own document, so an unopened overlay is an
// invisible, non-interactive Item and costs nothing.
AddToMixtapeModal {
anchors.fill: parent
}
MyQbzModals {
anchors.fill: parent
}
QbzToast {
anchors.fill: parent
}
// LAST child = above every surface, exactly like ArtPreviewOverlay.slint's
// mount in AppShell.slint. Non-interactive, so it never steals the hover
// that is keeping it open (see the file header).
+12 -7
View File
@@ -132,11 +132,15 @@ Rectangle {
leftPadding: navTab.showIcon ? 9 : 11
rightPadding: 11
spacing: 6
QbzIcon {
// Baked glyph, or the section's own raw image when it carries one
// (My QBZ branding) — see shell/NavSectionGlyph.qml.
NavSectionGlyph {
visible: navTab.showIcon
name: navTab.section ? navTab.section.icon : ""
section: navTab.section
size: 14
// Collapses to zero width under the 1140px breakpoint so the
// Row reclaims the space (the height stays 14 via `size`).
width: navTab.showIcon ? 14 : 0
height: 14
anchors.verticalCenter: parent.verticalCenter
// Both arms are THEME tokens (the tab sits on surface-elevated
// / surface-hover): "textPrimary", never the fixed-white
@@ -192,10 +196,11 @@ Rectangle {
opacity: isEnabled ? 1.0 : 0.5
color: isActive ? theme.surfaceElevated
: (cnbArea.containsMouse && isEnabled) ? theme.surfaceHover : "transparent"
QbzIcon {
name: cnb.section ? cnb.section.icon : ""
width: 16
height: 16
// Baked glyph, or the section's own raw image when it carries one
// (My QBZ branding) — see shell/NavSectionGlyph.qml.
NavSectionGlyph {
section: cnb.section
size: 16
anchors.centerIn: parent
tintName: cnb.isActive ? "textPrimary" : "secondary"
}
+83 -18
View File
@@ -27,10 +27,19 @@
// row — hence the two-flag OR, HeaderMenuOverlay.slint:41);
// - a press outside, or Escape, closes it.
//
// GAP vs Slint: no drop shadow. Qt's shadow effects (Qt5Compat /
// QtQuick.Effects MultiEffect) render NOTHING on the software path this port
// runs on (see RoundedImage.qml / TrackInfoModal.qml) — the 1px border-muted
// outline carries the separation instead.
// GAP vs Slint: no drop shadow — the 1px border-muted outline carries the
// separation instead.
//
// SUPERSEDED (2026-07-29), the REASON this gap used to give: "Qt's shadow
// effects (Qt5Compat / QtQuick.Effects MultiEffect) render NOTHING on the
// software path this port runs on". Effects need shaders. This port runs on
// the GPU (OpenGL RHI, measured 2026-07-29); the earlier "renders nothing"
// note came from an offscreen session, which forces the software renderer by
// definition. Where a software path is genuinely possible, detect it with
// `GraphicsInfo.api` rather than assuming it (theme/RoundedImage.qml does).
// The gap itself STAYS for now: adding the shadow is a visual change that
// needs its own parity pass against Slint's `drop-shadow-blur`, not a perf
// fix — do not add one in a domain commit.
import QtQuick
import QtQuick.Controls
@@ -61,6 +70,40 @@ Item {
}
}
// MyQBZ branding — `label` (user-editable in Settings > Appearance,
// coerced back to "My QBZ" on the Rust side when cleared), `iconPath` +
// `hasCustomIcon` for a user-supplied section icon. Published on
// QbzMyQbz.brandingJson, seeded at construction (not at boot()) precisely
// because this catalog binds it on the FIRST frame.
//
// The parse is GUARDED (the parseDoc idiom, views/PlaylistView.qml:53-60):
// a raw in-binding JSON.parse throws on the pre-publish frame and takes
// the whole nav catalog down with it — both hosts included, since both
// read `sections` from here. The try also covers QbzMyQbz not being
// registered at all (an unknown type name is a plain JS global lookup, so
// it raises a catchable ReferenceError rather than a load error).
readonly property var branding: parseBranding()
function parseBranding() {
try {
return JSON.parse(QbzMyQbz.brandingJson)
} catch (e) {
return ({})
}
}
// Rust never publishes an empty label — it coerces a cleared one back to
// the English literal "My QBZ" — so the localised fallback below is
// defensive only, and the SHIPPED default is that English literal. That is
// a pre-existing i18n hole shared with the Slint app
// (MyQbzBrandingState.label's default is the same literal); do not "fix"
// it here by second-guessing the Rust value.
readonly property string brandingLabel: {
var l = nav.branding.label || ""
return l !== "" ? l : nav.trs("My QBZ")
}
readonly property string brandingIconPath:
nav.branding.hasCustomIcon === true ? (nav.branding.iconPath || "") : ""
// The four top-level sections, in Slint order. `rev` is threaded through
// buildSections purely so the binding re-runs on a language switch
// (QbzSession.trRev is the translation revision counter every other
@@ -73,7 +116,11 @@ Item {
// view (shell route), tab (view-internal tab id),
// enabled }
// Section shape: { id, icon, label, qobuz (hidden while offline),
// enabled, entries }
// enabled, entries,
// iconPath — OPTIONAL: absent or "" means "use the baked
// `icon`"; a non-empty path is a user-supplied image
// rendered raw by shell/NavSectionGlyph.qml. Only the
// MyQBZ section can carry one. }
function buildSections(rev, reco) {
// Discover — glyph-less rows (Slint has-glyph: false).
var discover = [
@@ -122,23 +169,32 @@ Item {
]
},
{
// GAP: this port has NO My QBZ surface — the shell router maps
// home / library / local / localalbum / album / artist /
// settings / search / playlist, and neither Collections nor
// Mixtapes has a view behind it. The whole section is disabled
// (dimmed, pointer-inert, no flyout) rather than opening a menu
// whose every row clicks into nothing. The entries are kept
// here so the port stays legible against Slint.
"id": "myqbz",
"icon": "qbz-symbolic",
// Slint: MyQbzBrandingState.label (user-editable, defaults to
// "My QBZ"); this port has no branding state yet.
"label": nav.trs("My QBZ"),
// Slint's HEADER hosts label this tab @tr("My QBZ")
// (HeaderBar.slint:938, :1048) while only the sidebar and the
// kiosk rail read MyQbzBrandingState.label (Sidebar.slint:787,
// NavRail.slint:145). ONE catalog cannot express that split,
// and a user who renames the section expects the rename
// everywhere, so both hosts get the branding label.
"label": nav.brandingLabel,
// Custom section icon, rendered raw (its own colours, no theme
// tint) — Slint's SidebarNavRow `raw-icon` arm.
"iconPath": nav.brandingIconPath,
"qobuz": false,
"enabled": false,
"enabled": true,
"entries": [
{ "label": nav.trs("Collections"), "icon": "library-big", "view": "", "tab": "", "enabled": false },
{ "label": nav.trs("Mixtapes"), "icon": "cassette-tape", "view": "", "tab": "", "enabled": false }
// ORDER is the header's: Collections then Mixtapes
// (HeaderBar.slint:941-942). Slint's sidebar contradicts
// itself with Mixtapes first (Sidebar.slint:790-791), and
// one catalog serves both hosts.
// ICONS are the header's pair too: cassette-tape +
// library-big are baked in all eight tint dirs, while the
// sidebar's cassette + collection are not — preferring
// them would manufacture two asset imports for a visually
// equivalent glyph.
{ "label": nav.trs("Collections"), "icon": "library-big", "view": "collections", "tab": "", "enabled": true },
{ "label": nav.trs("Mixtapes"), "icon": "cassette-tape", "view": "mixtapes", "tab": "", "enabled": true }
]
}
]
@@ -147,10 +203,19 @@ Item {
// Which section owns the highlight, derived from the LIVE view (both hosts
// used to carry this identical binding). Slint highlights off
// HeaderMenuState.open-index; the hosts OR that in via `openId`.
//
// Each alternative spells `QbzShell.currentView === x` out in full: the
// shorthand `a === x || y || z` is ALWAYS truthy (`"y"` is a non-empty
// string), which would light the section up on every route in the app.
// "discobuilder" is deliberately NOT in the MyQBZ arm — Slint's own active
// predicate omits the builder (NavRail.slint:146-148).
readonly property string activeSection:
QbzShell.currentView === "home" ? "discover"
: QbzShell.currentView === "library" ? "library"
: (QbzShell.currentView === "local" || QbzShell.currentView === "localalbum") ? "local"
: (QbzShell.currentView === "mixtapes"
|| QbzShell.currentView === "collections"
|| QbzShell.currentView === "mixtapedetail") ? "myqbz"
: ""
// ---------------------------------------------------------------------
@@ -0,0 +1,51 @@
// The leading glyph of a nav SECTION, shared by the three hosts that render
// one: Sidebar.qml's NavRow, HeaderBar.qml's NavTab and its CompactNavBtn.
// The catalog (NavFlyout.sections) carries both an `icon` (baked glyph name)
// and an `iconPath` (a user-supplied image, "" when there is none), and the
// branch between them is Slint's SidebarNavRow `raw-icon` arm:
// a custom icon is rendered RAW so its own colours show, every other section
// keeps the theme-tinted glyph (Sidebar.slint:559-562, :601-612).
//
// It exists as its own component because the alternative is the identical
// two-way branch copy-pasted at three call sites (TRACK-RULES §5).
import QtQuick
import "../theme"
Item {
id: glyph
// A section object from NavFlyout.sections.
property var section: null
// Square edge, in px. Sites that need an asymmetric width (the header tab
// collapses its icon to 0 under the 1140px breakpoint) set `width` too.
property real size: 16
// QbzIcon tint for the baked-glyph arm; ignored by the raw arm.
property string tintName: "secondary"
implicitWidth: size
implicitHeight: size
readonly property string iconPath:
(section && section.iconPath) ? section.iconPath : ""
// Raw user icon — no tint, `contain` fit (Slint image-fit: contain).
Image {
anchors.fill: parent
visible: glyph.iconPath !== ""
source: glyph.iconPath
sourceSize: Qt.size(Math.max(1, Math.round(width * 2)),
Math.max(1, Math.round(height * 2)))
fillMode: Image.PreserveAspectFit
asynchronous: true
}
// Default branded glyph — tinted to the host's text colour.
QbzIcon {
anchors.fill: parent
visible: glyph.iconPath === ""
// Kept empty on the raw arm so the baked glyph is never loaded twice.
name: (glyph.section && glyph.iconPath === "") ? glyph.section.icon : ""
tintName: glyph.tintName
}
}
+14 -3
View File
@@ -457,10 +457,21 @@ Rectangle {
} else if (a === "album-favorite") {
if (QbzPlayer.npAlbumId !== "")
QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId)
} else if (a === "mixtape") {
// MyQBZ AddItem, built here from the now-playing state:
// `npArtworkPath` is a file:// CACHE path, so it is NOT the
// artworkUrl — the store would keep a dead local path.
if (id !== "")
QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": id, "title": QbzPlayer.npTitle,
"subtitle": QbzPlayer.npArtist, "artworkUrl": "",
"year": null, "trackCount": null
}]))
}
// TODO(qt-bridge): "playlist" (add-to-playlist modal) and
// "mixtape" have no invokable in the Qt port yet — the rows are
// rendered 1:1 with the Slint flyout and do nothing for now.
// TODO(qt-bridge): "playlist" (add-to-playlist modal) has no
// invokable in the Qt port yet — the row is rendered 1:1 with the
// Slint flyout and does nothing for now.
}
}
}
+17 -5
View File
@@ -46,8 +46,9 @@
// shell/CastPicker.qml (QbzCast — discovery, connect/disconnect, the
// per-device quality cap) and lights while a renderer is connected.
// Inert (TODO comments at the call sites): Connect (the QConnect device
// flyout), add-to-playlist, add-to-mixtape. The volume LOCK (ALSA hw /
// remote) is still not enforced.
// flyout) and add-to-playlist. add-to-mixtape is LIVE since the MyQBZ domain
// landed (QbzMyQbzAdd.open). The volume LOCK (ALSA hw / remote) is still not
// enforced.
//
// SIZE (project rule): the inline SongCard / TransportControls / FavToggle
// components moved out to shell/SongCard.qml, shell/TransportControls.qml and
@@ -511,10 +512,21 @@ Rectangle {
} else if (a === "album-favorite") {
if (QbzPlayer.npAlbumId !== "")
QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId)
} else if (a === "mixtape") {
// MyQBZ AddItem, built here from the now-playing state:
// `npArtworkPath` is a file:// CACHE path, so it is NOT the
// artworkUrl — the store would keep a dead local path.
if (id !== "")
QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": id, "title": QbzPlayer.npTitle,
"subtitle": QbzPlayer.npArtist, "artworkUrl": "",
"year": null, "trackCount": null
}]))
}
// TODO(qt-bridge): "playlist" (add-to-playlist modal) and
// "mixtape" have no invokable in the Qt port yet — the rows are
// rendered 1:1 with the Slint flyout and do nothing for now.
// TODO(qt-bridge): "playlist" (add-to-playlist modal) has no
// invokable in the Qt port yet — the row is rendered 1:1 with the
// Slint flyout and does nothing for now.
}
}
}
+19 -12
View File
@@ -38,9 +38,13 @@
// behave identically wherever the nav is hosted. The rows keep the
// SidebarDirectRow active treatment (surface-hover bg + primary text/icon)
// for the current section and while their own menu is open.
// Discover / Library / Local Library route into real views; My QBZ does NOT
// (no such surface in this port) and is therefore rendered disabled, flyout
// included (a row that clicks into nothing is a defect, not a stub).
// All four sections route into real views, My QBZ included (its Collections /
// Mixtapes entries reach the "collections" / "mixtapes" routes). The disabled
// treatment the NavRow still carries — dimmed to 0.45, pointer-inert, no
// flyout — is the mechanism for a section with no surface behind it, and
// nothing uses it today (a row that clicks into nothing is a defect, not a
// stub). Its leading glyph goes through shell/NavSectionGlyph.qml, because My
// QBZ can carry a user-supplied icon (SidebarNavRow `raw-icon`).
// The playlist/folder tree below the nav IS live (sidebar_qt.rs: load,
// sort, search, expand/collapse, drag-drop target); folder CREATION,
// the Playlist Manager and the importer have no seam and are disabled in
@@ -410,8 +414,9 @@ Rectangle {
property var section: null
readonly property bool isActive: section
&& (root.activeNav === section.id || navFlyout.openId === section.id)
// No surface behind this section in this port — rendered as
// unavailable rather than as a live row that clicks into nothing.
// A section with no surface behind it renders as unavailable rather
// than as a live row that clicks into nothing. Every section is live
// today; the treatment stays for the next stub.
readonly property bool isEnabled: section && section.enabled
readonly property bool lit: isActive || (navArea.containsMouse && isEnabled)
@@ -432,10 +437,11 @@ Rectangle {
Item {
width: root.mini ? parent.width : 16
height: parent.height
QbzIcon {
name: navRow.section ? navRow.section.icon : ""
width: 16
height: 16
// Baked glyph, or the section's own raw image when it carries
// one (My QBZ branding) — Sidebar.slint:601-612.
NavSectionGlyph {
section: navRow.section
size: 16
anchors.centerIn: parent
tintName: navRow.lit ? "textPrimary" : "secondary"
}
@@ -499,9 +505,10 @@ Rectangle {
// The same four sections the header hosts, from the same catalog.
// Qobuz-only ones are HIDDEN entirely while offline (ADR-010
// mount-site gating, not rendered-disabled); My QBZ carries
// enabled: false in the catalog (no surface behind it in this
// port) and so renders dimmed and inert, flyout included.
// mount-site gating, not rendered-disabled). My QBZ is `qobuz:
// false` in the catalog, i.e. it stays mounted while offline —
// Slint does not offline-gate its row either
// (Sidebar.slint:779-792, unlike the Purchases row right after it).
Repeater {
model: navFlyout.sections
delegate: NavRow {
+17 -5
View File
@@ -14,11 +14,13 @@
// - JUMP TO is NOT sticky. ArtistView.qml:1204 documents the same call for
// the .slint's sticky clamp; matching it keeps the two detail pages
// consistent. This is the one accepted visual delta.
// - NO multi-select. qml/rows/TrackRow.qml has no multi-select arm, and two
// of the .slint MultiSelectBar's seven actions (add-to-playlist,
// add-to-mixtape) have no bridge seam at all (TrackRow.qml:108-112), so a
// full port would ship two dead entries. The select disc renders DIMMED
// and inert (the ArtistView "Radio" precedent) and the bar is absent.
// - NO multi-select. qml/rows/TrackRow.qml has no multi-select arm, and one
// of the .slint MultiSelectBar's seven actions (add-to-playlist) has no
// bridge seam at all (TrackRow.qml:107-121), so a full port would ship a
// dead entry. (add-to-mixtape IS live now — the per-row menu reaches
// QbzMyQbzAdd.open; only the BULK form is missing, for want of a
// selection.) The select disc renders DIMMED and inert (the ArtistView
// "Radio" precedent) and the bar is absent.
// - NO "In library" tab. The Qt library feed carries no label id, so
// `libraryCount` is always 0 and the SegmentedTabBar never mounts (see
// src/label_qt.rs). A toggle that switches to an empty tab is worse.
@@ -520,6 +522,16 @@ Rectangle {
onEnqueueRequested: function (mode) {
QbzHome.labelEnqueueTrack(modelData.id, mode)
}
// MyQBZ "Add to mixtape" — the HOST builds the
// AddItem array (TrackRow does not know
// itemType/source).
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": modelData.id,
"title": modelData.title || "",
"subtitle": modelData.artist || "",
"artworkUrl": "", "year": null, "trackCount": null
}]))
}
}
+9
View File
@@ -1427,6 +1427,15 @@ Rectangle {
number: index + 1
onPlayRequested: QbzPlayer.playTrack(item.id)
onEnqueueRequested: function (m) { QbzPlayer.enqueueTrack(item.id, m) }
// MyQBZ "Add to mixtape" — the HOST builds the
// AddItem array (TrackRow does not know
// itemType/source).
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": item.id, "title": item.title || "",
"subtitle": item.artist || "", "artworkUrl": "",
"year": null, "trackCount": null
}]))
}
}
Component {
+16 -5
View File
@@ -28,11 +28,13 @@
// rows/TrackCols.qml for both.
//
// POC-NOTEs, each deliberate and each with a precedent in this port:
// - NO multi-select bar. qml/rows/TrackRow.qml has no multi-select arm and
// two of the .slint bar's seven actions (add-to-playlist, add-to-mixtape)
// have no bridge seam at all — LabelView.qml made the same call. The
// .slint header's select disc renders DIMMED and inert here (the
// ArtistView / LabelView precedent).
// - NO multi-select bar. qml/rows/TrackRow.qml has no multi-select arm, and
// one of the .slint bar's seven actions (add-to-playlist) still has no
// bridge seam at all — LabelView.qml made the same call. (add-to-mixtape
// IS live now: the per-row menu reaches QbzMyQbzAdd.open; it is only the
// BULK form that is missing, for want of a selection.) The .slint header's
// select disc renders DIMMED and inert here (the ArtistView / LabelView
// precedent).
// - The "Add to playlist" disc (list-plus) is inert in the .slint too — it
// carries no `clicked` there (MixView.slint:182-188) — so it is inert
// here, dimmed the same way, rather than silently absent.
@@ -281,6 +283,15 @@ Rectangle {
onEnqueueRequested: function (mode) {
QbzHome.mixEnqueueTrack(modelData.id, mode)
}
// MyQBZ "Add to mixtape" — the HOST builds the AddItem
// array (TrackRow does not know itemType/source).
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": modelData.id,
"title": modelData.title || "",
"subtitle": modelData.artist || "",
"artworkUrl": "", "year": null, "trackCount": null
}]))
}
}
}
+8
View File
@@ -645,6 +645,14 @@ Rectangle {
onPlayRequested: QbzBridge.playlistPlayTrack(item.id)
onEnqueueRequested: function (m) { QbzBridge.playlistEnqueueTrack(item.id, m) }
onRemoveRequested: QbzBridge.playlistRemoveTrack(item.playlistTrackId)
// MyQBZ "Add to mixtape" — the HOST builds the AddItem
// array (TrackRow does not know itemType/source).
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": item.id, "title": item.title || "",
"subtitle": item.artist || "", "artworkUrl": "",
"year": null, "trackCount": null
}]))
onBodyDragStarted: function (n) {
// #589: report the source index BEFORE the shared drag.
if (root.isOwner) {
+9
View File
@@ -702,6 +702,15 @@ Rectangle {
menuShowFavorite: false
onPlayRequested: QbzPlayer.playTrack(item.id)
onEnqueueRequested: function (m) { QbzPlayer.enqueueTrack(item.id, m) }
// MyQBZ "Add to mixtape" — the HOST builds the
// AddItem array (TrackRow does not know
// itemType/source).
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": item.id, "title": item.title || "",
"subtitle": item.artist || "", "artworkUrl": "",
"year": null, "trackCount": null
}]))
}
}
}
@@ -89,7 +89,7 @@ Item {
visible: !QbzLocal.localAlbumsLoading && QbzLocal.localAlbumsError === ""
&& root.view.albumsVisible.length > 0
LocalMultiSelectBar {
QbzMultiSelectBar {
visible: root.view.albumsMultiSelect
width: parent.width
selectedCount: root.view.albumsSelectedCount
@@ -134,7 +134,7 @@ Item {
placeholder: QbzSession.tr("Search albums...", QbzSession.trRev)
onEdited: function (v) { root.view.folderDetailSearch = v }
}
LocalSegToggle {
QbzSegToggle {
anchors.verticalCenter: parent.verticalCenter
segments: [{ "id": "grid", "icon": "layout-grid" },
{ "id": "list", "icon": "list" }]
@@ -1,61 +0,0 @@
// Two-segment icon toggle — ONE component for both ViewToggle (grid/list,
// LocalLibraryView.slint:358) and ModeToggle (flat/tree, :384). Slint keeps
// them as two components because its ToggleButton cannot take a model; both
// bodies are otherwise byte-identical (60x30, radius 6, surface-elevated,
// two ToggleButton `sm` children), so here they are one component driven by
// a `segments` model.
//
// ADR-008: small-radius rectangle, never a pill.
import QtQuick
import com.blitzfc.qbz
import "../../theme"
Rectangle {
id: root
/// [{ id, icon }] — exactly two entries on every current call site.
property var segments: []
property string mode: ""
signal setMode(string value)
QbzTheme { id: theme }
width: 60
height: 30
radius: 6
color: theme.surfaceElevated
clip: true
Row {
anchors.centerIn: parent
spacing: 2
Repeater {
model: root.segments
delegate: Rectangle {
id: seg
required property var modelData
readonly property bool active: modelData.id === root.mode
width: 26
height: 24
radius: 4
color: active ? theme.surfaceMain
: segArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: seg.modelData.icon
width: 14
height: 14
anchors.centerIn: parent
tintName: seg.active ? "accent" : "secondary"
}
MouseArea {
id: segArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.setMode(seg.modelData.id)
}
}
}
}
}
@@ -177,7 +177,7 @@ Row {
}
}
}
LocalSegToggle {
QbzSegToggle {
anchors.verticalCenter: parent.verticalCenter
segments: [{ "id": "grid", "icon": "layout-grid" },
{ "id": "list", "icon": "list" }]
@@ -275,7 +275,7 @@ Row {
currentIndex: Math.max(0, root.groupIds.indexOf(root.view.foldersGroup))
onSelected: function (i) { root.view.foldersGroup = root.groupIds[i] }
}
LocalSegToggle {
QbzSegToggle {
anchors.verticalCenter: parent.verticalCenter
segments: [{ "id": "grid", "icon": "layout-grid" },
{ "id": "list", "icon": "list" }]
@@ -287,7 +287,7 @@ Row {
// ASSET GAP: Slint uses disc-album / folder-tree; neither glyph is
// baked in the Qt icon set yet (see GLUE), so this uses the closest
// shipped pair.
LocalSegToggle {
QbzSegToggle {
anchors.verticalCenter: parent.verticalCenter
segments: [{ "id": "flat", "icon": "disc" },
{ "id": "tree", "icon": "folder-open" }]
@@ -106,7 +106,7 @@ Item {
spacing: 8
visible: !QbzLocal.localTracksLoading && root.view.tracksVisible.length > 0
LocalMultiSelectBar {
QbzMultiSelectBar {
visible: root.view.tracksMultiSelect
width: parent.width
selectedCount: root.view.tracksSelectedCount
+60 -12
View File
@@ -1,11 +1,38 @@
// Per-version source glyph (album/LocalAlbumView.slint:23 SourceIcon):
// hard-drive for a local file, the Qobuz mark for an offline/purchased copy
// (gold when purchased), the Plex mark for Plex.
// Per-version / per-row source glyph: hard-drive for a local file, the Qobuz
// mark for an offline/purchased copy (gold when purchased), the Plex mark for
// Plex.
//
// ASSET GAP: the Qt icon set has no qobuz-logo-filled / plex-logo variant
// yet (see GLUE), so the offline copy reuses cloud-download and Plex reuses
// hard-drive in the accent tint — the same substitution the Local Library
// cards already ship.
// ── IT STANDS IN FOR TWO SLINT COMPONENTS, WHICH DISAGREE ──────────────────
// `album/LocalAlbumView.slint:22-37` (`SourceIcon`) — the VERSION PICKER's
// glyph: 14px, and the monochrome local glyph tinted `Theme.text-SECONDARY`.
// `primitives/SourceGlyph.slint:15-32` — the shared row glyph used by the MyQBZ
// collection-detail rows (`MixtapeDetailView.slint:370`), the Discography
// Builder rows (`DiscographyBuilderView.slint:54`), FavoritesView and TrackRow:
// 15px, and the local glyph tinted `Theme.text-MUTED`, never accent.
// (They also disagree on plain `"qobuz"`: LocalAlbumView lets it fall through to
// hard-drive, SourceGlyph gives it the Qobuz mark. This file follows SourceGlyph
// there — see `kind` below.)
//
// So the two numbers that differ are PROPERTIES, and the DEFAULTS are the
// version-picker set, which is what `views/local/VersionPicker.qml` (the only
// two call sites that set neither) needs. A row that stands for SourceGlyph
// passes `glyphSize: 15` and `localTint: "muted"`.
//
// SourceGlyph's other size rule — 16px for the three qobuz kinds
// (`SourceGlyph.slint:29`) — is deliberately NOT reproduced: it exists to
// compensate the Qobuz WORDMARK's proportions, and this port does not draw that
// glyph (see the asset gap). Widening a `cloud-download` substitute to 16 would
// be a number with no reference behind it.
//
// ASSET GAP: the Qt icon set has NO `qobuz-logo-filled.svg` and NO
// `plex-logo.svg` — neither an untinted master nor any tint directory
// (`qml/assets/icons/{accent,amber,black,favorite,muted,primary,secondary,warning}/`
// and `qml/assets/brand/` all verified). Both are present in the Slint tree
// (`crates/qbz-ui/ui/assets/icons/`). Until they are ported the offline/purchased
// copy reuses `cloud-download` and Plex reuses `hard-drive` in the accent tint —
// the same substitution the Local Library cards already ship — and the "logos
// render UNTINTED" rule those two arms exist for cannot be honoured at all,
// because a substitute monochrome glyph has to be tinted to read.
import QtQuick
import "../../theme"
@@ -13,13 +40,34 @@ import "../../theme"
QbzIcon {
id: root
/// "local" | "qobuz_download" | "qobuz_purchase" | "offline" | "plex"
/// "local" | "qobuz" | "qobuz_download" | "qobuz_purchase" | "offline" | "plex"
///
/// Plain "qobuz" is what the MyQBZ collection items carry (a streamed Qobuz
/// album/track, not a download or a purchase). Without its own arm it fell
/// through to "hard-drive", i.e. every Qobuz row in a collection claimed to
/// be a local file. `SourceGlyph.slint:23` groups it with the downloads for
/// the same reason.
property string kind: "local"
width: 14
height: 14
name: (kind === "qobuz_download" || kind === "qobuz_purchase" || kind === "offline")
/// 14 = album/LocalAlbumView.slint:24 (the version picker).
/// 15 = primitives/SourceGlyph.slint:29-30 (every row glyph).
property int glyphSize: 14
/// Tint of the MONOCHROME local hard-drive glyph. "secondary" =
/// LocalAlbumView.slint:36; "muted" = SourceGlyph.slint:28, whose header
/// calls the muted-never-accent rule load-bearing.
property string localTint: "secondary"
width: root.glyphSize
height: root.glyphSize
name: (kind === "qobuz" || kind === "qobuz_download"
|| kind === "qobuz_purchase" || kind === "offline")
? "cloud-download" : "hard-drive"
// `warning` for the purchase gold (#eab308 in both Slint components) and
// `accent` to keep the Plex substitute distinguishable from a local file;
// everything else is the monochrome local glyph.
tintName: kind === "qobuz_purchase" ? "warning"
: kind === "plex" ? "accent" : "secondary"
: kind === "plex" ? "accent"
: (kind === "qobuz" || kind === "qobuz_download" || kind === "offline")
? "secondary"
: root.localTint
}
@@ -0,0 +1,535 @@
// Discography Builder — the full-page flow that assembles an artist's complete
// discography into a saved `kind='artist_collection'` collection. QML port of
// myqbz/DiscographyBuilderView.slint:418-787 (spec 01 §8).
//
// Route "discobuilder". Entry: the artist page's overflow -> "Create Artist
// Collection". Exit: the footer Cancel — there is NO per-view back button
// (:438: the shell owns the global < > nav).
//
// TWO-PART SHELL (:8-11, :422-428, :727-736): everything scrolls except a
// FIXED 60px footer pinned at the bottom as a sibling of the scroller (NOT
// sticky-inside-scroll), so the last rows never peek past it.
//
// WINDOWING (spec 01 §8.9): a discography is tens-to-hundreds of groups and
// every row mounts a QualityBadgeFull, so the table is ONE recycled ListView
// over the FLATTENED row descriptors Rust publishes as `doc.rows`
// (`{kind, groupKey, groupIsCompilation, restOpacity, candidate}`), with
// `cacheBuffer: 44*10`. NOT a ListView of groups each holding a Repeater of
// alternates: that shape leaves `rows` unread and makes every delegate a
// variable-height Column. Everything above the table — the artist header,
// the NAME field, the ORDER BY row, the 20px spacer, the loading/error/empty
// band and the table's own rule + column header — is the ListView.header, so
// ONE scroller drives the page; the content's 24px bottom padding is the
// ListView.footer. In the three non-populated states the model is empty and
// the header carries the whole page: the ListView is never branched out of the
// tree, because a new `model` assignment resets contentY (recipe §8).
//
// The root is `color: "transparent"` VERBATIM (:419) — not the ambient ternary
// the other views use, and therefore no `radius: 12` either: there is no fill
// of its own to round, the content frame's surfaceMain shows through, and the
// footer's surfaceMain matches it (AppShell's bezel nubs own the corners).
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
import "../local"
Rectangle {
id: root
color: "transparent"
QbzTheme { id: theme }
readonly property var doc: parseDoc()
function parseDoc() {
try {
return JSON.parse(QbzDisco.discoJson)
} catch (e) {
return ({})
}
}
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// --- Document slices (spec 02 §5.8 DiscoDoc) --------------------------
readonly property string artistId: doc.artistId || ""
readonly property bool loading: doc.loading === true
readonly property string loadError: doc.loadError || ""
readonly property var groups: doc.groups || []
// The flattened table (spec 01 §8.9) — one entry per RENDERED row, in the
// same order as `groups`, each carrying its precomputed `restOpacity`.
readonly property var rows: doc.rows || []
readonly property string orderBy: doc.orderBy || "release_date"
readonly property int selectedCount: doc.selectedCount || 0
readonly property int groupCount: doc.groupCount || 0
readonly property bool creating: doc.creating === true
readonly property string defaultName: doc.defaultName || ""
// Precedence exactly as the Slint writes it (:599,610,621-623,634-636):
// loading -> loadError -> empty -> the table.
readonly property bool populated: !root.loading && root.loadError === ""
&& root.groups.length > 0
// --- Collection name (QML-local) --------------------------------------
// The bridge (spec 02 §4.3) has no `setName`: `create(name)` takes it, so
// the field's truth lives here and Rust seeds it once through
// `defaultName` ("{artist} — Complete Discography").
// DIVERGENCE from spec 01 §8.7, forced by that surface: Slint cannot trim
// and exposes `name-trimmed-empty` from Rust; with one implementation
// there is nothing to diverge from, so the trim is done here.
property string collectionName: ""
property bool nameEdited: false
readonly property bool nameTrimmedEmpty: root.collectionName.trim() === ""
// A new artist re-seeds; a late-arriving default (the name resolves after
// the fetch) must not clobber what the user typed.
onArtistIdChanged: {
root.nameEdited = false
root.collectionName = root.defaultName
}
onDefaultNameChanged: if (!root.nameEdited) root.collectionName = root.defaultName
// Slint's `reset` blanks `collection-name` before EVERY fetch
// (myqbz_builder.rs:690) and `install` re-seeds it, so a name the user typed
// during a previous open never survives into the next one. Without this the
// `nameEdited` latch keeps a stale edit forever when the builder is
// re-opened for the SAME artist (`artistId` and `defaultName` both unchanged,
// so neither handler above fires).
onLoadingChanged: if (root.loading) {
root.nameEdited = false
root.collectionName = ""
// A fresh load is a fresh table: without this the preserved offset
// (below) survives into the NEXT artist and `restoreScroll` opens his
// discography already scrolled to the previous one's row. Slint's
// Flickable lands at the top because `reset` empties the viewport.
root.keepY = 0
}
Component.onCompleted: if (!root.nameEdited && root.collectionName === "")
root.collectionName = root.defaultName
// Footer count. Rust pre-renders the ONE true plural in this domain with
// `qbz_i18n::tf` and publishes it as `footerCount` (spec 01 §4/§8.7) —
// QML has no plural entry point. The fallback below only runs if that key
// is absent, and then falls through to the English source (an untranslated
// msgid renders, it does not break — recipe §7).
readonly property string footerText: {
var pre = root.doc.footerCount || ""
if (pre !== "")
return pre
var src = root.groupCount === 1
? "{} of {} album selected" : "{} of {} albums selected"
return root.t(src).replace("{}", String(root.selectedCount))
.replace("{}", String(root.groupCount))
}
/// The six column labels (:677-700) — uppercase literals for the same
/// reason as the eyebrow, `letterSpacing 1.2`, and the per-cell alignment
/// the Slint gives each one. `titleW` is the 1fr ALBUM cell.
function headCells(rev, titleW) {
return [
{ "w": 56, "label": QbzSession.tr("YEAR", rev), "center": false },
{ "w": 72, "label": QbzSession.tr("TYPE", rev), "center": false },
{ "w": titleW, "label": QbzSession.tr("ALBUM", rev), "center": false },
{ "w": 72, "label": QbzSession.tr("TRACKS", rev), "center": true },
{ "w": 60, "label": QbzSession.tr("SOURCE", rev), "center": true },
{ "w": 156, "label": QbzSession.tr("QUALITY", rev), "center": true }
]
}
/// ORDER BY segments (:552-555). Built by a function that TAKES `rev` so
/// the array rebuilds on a live language switch.
function orderSegments(rev) {
return [
{ "id": "release_date", "label": QbzSession.tr("Release date", rev) },
{ "id": "title", "label": QbzSession.tr("Title", rev) },
{ "id": "manual", "label": QbzSession.tr("Manual", rev) }
]
}
// --- Scroll preservation ----------------------------------------------
// Every mutation (a checkbox, an override, a re-sort) republishes the whole
// DiscoDoc, so `groups` is a NEW array and QQuickItemView::setModel()
// resets contentY to 0 — one checkbox click would throw the user from row
// 40 to the top. Slint's Flickable keeps its viewport-y across the same
// republish, so restoring it IS the parity behaviour.
property real keepY: 0
onRowsChanged: Qt.callLater(root.restoreScroll)
function restoreScroll() {
if (root.keepY <= 0 || rowList.contentY > 0)
return
rowList.contentY = Math.min(
root.keepY, Math.max(0, rowList.contentHeight - rowList.height))
}
// --- Everything above the table (the ListView header) ------------------
Component {
id: pageHead
Column {
width: rowList.width
spacing: 0
// content padding-top (:433).
Item { width: 1; height: 24 }
// ── header.builder-header (:442-486) ──
Row {
width: parent.width
spacing: 20
// 72px circle. The Slint draws NO placeholder glyph — an
// artist without a portrait is the bare surface (:446-458).
Rectangle {
width: 72
height: 72
radius: 36
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
visible: (root.doc.artistAvatarUrl || "") !== ""
source: root.doc.artistAvatarPath || ""
radius: 36
}
}
Column {
width: Math.max(0, parent.width - 72 - 20)
anchors.verticalCenter: parent.verticalCenter
spacing: 4
// Uppercase in the msgid: Slint has no text-transform and
// writes the literal in caps (:465-471), and the eight
// catalogues carry it that way.
Text {
text: root.t("BUILD ARTIST COLLECTION")
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Text {
width: parent.width
text: (root.doc.artistName || "") === ""
? "—" : root.doc.artistName
color: theme.textPrimary
font.pixelSize: 28
font.weight: theme.weightBold
elide: Text.ElideRight
}
}
}
Item { width: 1; height: 24 }
// ── label.field — the collection-name input (:489-524) ──
Column {
width: parent.width
spacing: 8
Text {
text: root.t("NAME")
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
// Inlined rather than QbzLineEdit's plain arm: that arm is
// 240x34 / borderSubtle and this field is a one-off 540x40
// bordered `surfaceElevated` (spec 01 §8.3). Do not fork the
// shared control for it.
Rectangle {
width: 540
height: 40
color: theme.surfaceCard
radius: 8
border.width: 1
border.color: nameField.activeFocus
? theme.accent : theme.surfaceElevated
TextInput {
id: nameField
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
color: theme.textPrimary
font.pixelSize: 14
verticalAlignment: Text.AlignVCenter
clip: true
selectByMouse: true
text: root.collectionName
onTextEdited: {
root.nameEdited = true
root.collectionName = text
}
// Re-seed from Rust while the field is NOT being
// edited (the QbzLineEdit.qml:174-179 idiom).
Binding {
target: nameField
property: "text"
value: root.collectionName
when: !nameField.activeFocus
}
}
}
}
Item { width: 1; height: 16 }
// ── .order-row — the segmented control (:527-594) ──
Row {
width: parent.width
spacing: 12
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.t("ORDER BY")
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
// A THIRD toggle shape (32px tall, labelled, bordered box) —
// neither QbzSegToggle (icon segments) nor QbzToolButton, and
// one call site, so it stays inline (spec 01 §8.4).
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: segRow.implicitWidth
height: 32
radius: 8
border.width: 1
border.color: theme.surfaceElevated
clip: true
Row {
id: segRow
height: parent.height
spacing: 0
Repeater {
model: root.orderSegments(QbzSession.trRev)
delegate: Rectangle {
id: seg
required property var modelData
required property int index
readonly property bool active:
root.orderBy === seg.modelData.id
// padding-l/r 14 (:572-573).
width: segLabel.implicitWidth + 28
height: parent.height
// NOT accent (ADR-008, :558-563): a surface
// highlight, told apart from hover by tone +
// the bolder label.
color: seg.active
? theme.surfaceHover
: (segArea.containsMouse
? theme.surfaceElevated : "transparent")
Rectangle {
visible: seg.index > 0
x: 0
width: 1
height: parent.height
color: theme.surfaceElevated
}
Text {
id: segLabel
anchors.centerIn: parent
text: seg.modelData.label
color: (seg.active || segArea.containsMouse)
? theme.textPrimary : theme.textSecondary
font.pixelSize: 12
font.weight: seg.active
? theme.weightSemibold : theme.weightMedium
}
MouseArea {
id: segArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzDisco.setOrder(seg.modelData.id)
}
}
}
}
}
}
Item { width: 1; height: 20 }
// ── loading / error / empty — one 96px band (:597-632) ──
Item {
width: parent.width
visible: !root.populated
height: visible ? 96 : 0
Text {
anchors.fill: parent
text: root.loading
? root.t("Loading...")
: (root.loadError !== ""
? root.loadError : root.t("No results found"))
// The Slint uses Theme.favorite here, not Theme.danger
// (:614) — keep it.
color: (!root.loading && root.loadError !== "")
? theme.favorite : theme.textMuted
font.pixelSize: 14
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
// ── .groups table preamble: border-top + 4px pad (:639-640) ──
Rectangle {
width: parent.width
visible: root.populated
height: visible ? 1 : 0
color: theme.surfaceElevated
}
Item { width: 1; visible: root.populated; height: visible ? 4 : 0 }
// ── header row (.row.header-row, :643-702) ──
// The 7-column template is inlined here and in DiscoCandidateRow —
// a MATCHED PAIR. 28/56/72/1fr/72/60/156, padding 8, spacing 10.
Item {
id: colHead
width: parent.width
visible: root.populated
height: visible ? 36 : 0
readonly property real titleW: Math.max(
0, colHead.width - 16 - 444 - 60)
Rectangle {
y: parent.height - 1
width: parent.width
height: 1
color: theme.surfaceElevated
}
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 10
// [0] 28px — tri-state select-all. The indeterminate state
// renders minus.svg inside the same accent disc (the
// SelectCheck/TreeRow idiom), never a hand-drawn dash.
Item {
width: 28
height: parent.height
SelectCheck {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
on: root.doc.allChecked === true
partial: root.doc.allChecked !== true
&& root.doc.someChecked === true
onToggled: QbzDisco.toggleAll()
}
}
// [1..6] — the column labels, in order.
Repeater {
model: root.headCells(QbzSession.trRev, colHead.titleW)
delegate: Text {
required property var modelData
width: modelData.w
height: parent.height
text: modelData.label
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.2
horizontalAlignment: modelData.center
? Text.AlignHCenter : Text.AlignLeft
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
}
}
}
// content padding-bottom (:436).
Component {
id: bottomPad
Item { width: 1; height: 24 }
}
// --- The scroller (:424-428) -----------------------------------------
ListView {
id: rowList
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: footerBar.top
// content padding-l/r 32 (:431-432); the scrollbar gutter sits inside
// the right padding, so it never overlaps a row.
anchors.leftMargin: 32
anchors.rightMargin: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
// spec 01 §8.9: 44 * 10.
cacheBuffer: 440
model: root.populated ? root.rows : []
header: pageHead
footer: bottomPad
onContentYChanged: if (contentY > 0) root.keepY = contentY
// ONE row — a group's primary, or one of its alternates (:705-722).
// Every field comes off the Rust row descriptor: `restOpacity` is
// precomputed there (primary = isCompilation ? 0.65 : 1.0, alternate =
// 0.72 unconditionally), so it is never re-derived here.
delegate: DiscoCandidateRow {
required property var modelData
width: rowList.width
candidate: modelData.candidate || ({})
groupKey: modelData.groupKey || ""
alt: modelData.kind === "alt"
groupIsCompilation: modelData.groupIsCompilation === true
restOpacity: modelData.restOpacity !== undefined
? modelData.restOpacity : 1.0
}
}
QbzScrollBar {
target: rowList
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: rowList.top
anchors.bottom: rowList.bottom
}
// --- footer.builder-footer (:728-785) --------------------------------
Rectangle {
id: footerBar
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 60
color: theme.surfaceMain
Rectangle {
y: 0
width: parent.width
height: 1
color: theme.surfaceElevated
}
Text {
anchors.left: parent.left
anchors.leftMargin: 32
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, parent.width - 32 - 88 - actionRow.width - 16)
text: root.footerText
color: theme.textSecondary
font.pixelSize: 13
elide: Text.ElideRight
}
Row {
id: actionRow
// Asymmetric right padding (:739) — 88 clears the back-to-top FAB.
anchors.right: parent.right
anchors.rightMargin: 88
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: root.t("Cancel")
btnHeight: 36
minWidth: 0
onClicked: QbzDisco.back()
}
QbzPrimaryButton {
label: root.creating
? root.t("Loading...") : root.t("Create Collection")
btnHeight: 36
btnEnabled: !(root.creating || root.selectedCount === 0
|| root.nameTrimmedEmpty)
onClicked: QbzDisco.create(root.collectionName)
}
}
}
}
@@ -0,0 +1,448 @@
// One candidate row of the Discography Builder table — QML port of
// myqbz/DiscographyBuilderView.slint:248-416 (`CandidateRow`), carrying with
// it the three helpers Slint declares in the same file: `TypeOverride` +
// its popover (:62-240), `TypeColor` (:38-46) and `SourceIndicator` (:50-59).
//
// Slint keeps all four inside the view file; here the row is its own file so
// DiscoBuilderView.qml holds only the page frame (spec 01 §8.5), and the
// type-override menu stays with the button it belongs to — one call site.
//
// 7-COLUMN GRID, the alignment backbone (Slint :242-244):
// 28 / 56 / 72 / 1fr / 72 / 60 / 156, padding-l/r 8, spacing 10.
// The header row in DiscoBuilderView.qml inlines the SAME template — they are
// a MATCHED PAIR (the AlbumListHeader / AlbumListRow convention). Change both.
//
// `alt` (an alternate release inside a group) dims the row, swaps the col[0]
// checkbox for a connector + checkbox, and shrinks every text by 1px.
//
// HOVER is ORed across the MouseAreas this row owns, because a child
// MouseArea with `hoverEnabled` steals hover from the row-wide one — the same
// reason rows/TrackRow.qml:213 ORs its four areas. SelectCheck does not expose
// its internal area, so hovering the 13px checkbox itself does not lift the
// row; TrackRow lives with the identical limitation on its artist/album links.
//
// TYPE MENU: Slint hand-rolls a Rectangle plus a 4000x4000 backdrop TouchArea
// and a global `open-type-menu-key` because it has no popup that closes on
// outside-click (:114-127). Qt has one, so `QbzContextMenu` owns the open
// state and that global does NOT exist on the bridge (spec 01 §8.6). The
// popup is a delegate child and dies when this row is recycled out of the
// ListView — accepted (spec 01 §3.3.6): Slint's menu is a row child too.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
import "../local"
Rectangle {
id: root
/// One `DiscoCandidate` (spec 02 §5.8): key, source, sourceItemId, title,
/// year, tracks, releaseType, isOverridden, qualityTier, qualityDetail,
/// checked. (`artUrl`/`artPath` are unused — the table has no art cell.)
property var candidate: ({})
/// The OWNING group's key — `toggleChecked` is keyed by (group, candidate).
property string groupKey: ""
/// Alternate row (rendered under its group's primary).
property bool alt: false
/// Whether the OWNING group is a compilation. Slint :253-255: the
/// "compilation" tag is driven by the GROUP flag, not by the candidate's
/// effective release type.
property bool groupIsCompilation: false
/// Resting opacity, lifted to 1.0 on hover (:263). The parent passes it:
/// primary = `groupIsCompilation ? 0.65 : 1.0`, alternate = 0.72
/// unconditionally (:713,720 — an alternate inside a compilation group is
/// still 0.72).
property real restOpacity: 1.0
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// --- Column geometry (the matched pair — see the header note) ----------
readonly property int padH: 8
readonly property int colGap: 10
readonly property int colCheck: 28
readonly property int colYear: 56
readonly property int colType: 72
readonly property int colTracks: 72
readonly property int colSource: 60
readonly property int colQuality: 156
// 28 + 56 + 72 + 72 + 60 + 156 = 444 fixed, 6 gaps of 10 = 60.
readonly property real titleWidth: Math.max(
0, root.width - 2 * root.padH - 444 - 6 * root.colGap)
readonly property string releaseType: root.candidate.releaseType || ""
readonly property bool isOverridden: root.candidate.isOverridden === true
readonly property bool hovered: rowArea.containsMouse || titleArea.containsMouse
|| typeArea.containsMouse
/// Per-release-type tint (Slint `DiscoTint`, :30-35 / `TypeColor` :38-46).
/// The ONE legitimately-hardcoded palette in this view — spec 01 §2 keeps
/// these four as literals ("the spec intends fixed non-token colours");
/// plain "album" falls through to the muted token.
function typeColor(kind) {
return kind === "compilation" ? "#f59e0b"
: kind === "live" ? "#e91e63"
: kind === "ep" ? "#60a5fa"
: kind === "single" ? "#a78bfa"
: theme.textMuted
}
/// Menu rows in Slint's order (:142-147). Built by a function that TAKES
/// `rev` so the array rebuilds on a live language switch.
function typeChoices(rev) {
return [
{ "id": "album", "label": QbzSession.tr("Album", rev) },
{ "id": "ep", "label": QbzSession.tr("EP", rev) },
{ "id": "single", "label": QbzSession.tr("Single", rev) },
{ "id": "live", "label": QbzSession.tr("Live", rev) },
{ "id": "compilation", "label": QbzSession.tr("Compilation", rev) }
]
}
height: 44
radius: 6
color: root.hovered ? theme.surfaceHover : "transparent"
opacity: root.hovered ? 1.0 : root.restOpacity
// The row's own area does NOTHING but drive the hover lift (:265 —
// `mouse-cursor: default`, no handler). Declared BEFORE the cells so
// every interactive child sits above it.
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.ArrowCursor
}
// Bottom hairline, so adjacent rows (and their quality badges) read as
// separated instead of stuck together (:269-273).
Rectangle {
y: parent.height - 1
width: parent.width
height: 1
color: theme.borderSubtle
}
Row {
anchors.fill: parent
anchors.leftMargin: root.padH
anchors.rightMargin: root.padH
spacing: root.colGap
// [0] 28px — checkbox (alternate: connector + checkbox).
Item {
width: root.colCheck
height: parent.height
Row {
anchors.left: parent.left
height: parent.height
spacing: 3
Text {
visible: root.alt
anchors.verticalCenter: parent.verticalCenter
// Untranslated in Slint too (:286) — a glyph, not copy.
text: "↳"
color: theme.textMuted
font.pixelSize: 12
}
SelectCheck {
anchors.verticalCenter: parent.verticalCenter
on: root.candidate.checked === true
onToggled: QbzDisco.toggleChecked(root.groupKey,
root.candidate.key || "")
}
}
}
// [1] 56px — year.
Text {
width: root.colYear
height: parent.height
text: root.candidate.year || ""
color: root.alt ? theme.textMuted : theme.textSecondary
font.pixelSize: root.alt ? 12 : 13
horizontalAlignment: Text.AlignLeft
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// [2] 72px — release-type override button + popover.
Item {
width: root.colType
height: parent.height
Rectangle {
id: typeBtn
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
// Slint sizes the wrapper to the button's preferred size
// (:71-72) and the button is a 4/2-padded label (:79-83).
implicitWidth: typeLabel.implicitWidth + 8
implicitHeight: typeLabel.implicitHeight + 4
width: implicitWidth
height: implicitHeight
radius: 4
color: typeArea.containsMouse ? theme.surfaceHover : "transparent"
Text {
id: typeLabel
x: 4
y: 2
text: root.releaseType
color: root.typeColor(root.releaseType)
font.pixelSize: root.alt ? 10 : 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.4
}
// Active-override marker. Slint has no text-decoration and
// draws a 1px rule under the label (:96-103); QML's
// font.underline exists but the rule IS the reference shape.
Rectangle {
visible: root.isOverridden
x: typeLabel.x
y: parent.height - 3
width: typeLabel.width
height: 1
color: root.typeColor(root.releaseType)
opacity: 0.6
}
MouseArea {
id: typeArea
anchors.fill: parent
anchors.margins: -4 // the .type-btn `margin: 0 -4px` bleed
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
// CloseOnPressOutside fires BEFORE this handler, so
// without the guard a click on an open menu's trigger
// closes and immediately reopens it (spec 01 §3.3.5).
if (typeMenu.opened) {
typeMenu.close()
return
}
typeMenu.openBelowLeft(typeBtn)
}
}
}
// Slint: `x: 0; y: parent.height + 4`, 160px (:128-131) — LEFT
// aligned, hence openBelowLeft (openBelowRight would push a 160px
// panel ~120px left of this ~40px button).
QbzContextMenu {
id: typeMenu
menuWidth: 160
// The component hardcodes padding 5 / surfaceMain / r8 /
// borderMuted; this panel is 4 / surfaceCard / r6 /
// surfaceElevated (:132-135), so both are overridden here
// (spec 01 §3.3.1-2).
padding: 4
background: Rectangle {
color: theme.surfaceCard
radius: 6
border.width: 1
border.color: theme.surfaceElevated
}
Repeater {
model: root.typeChoices(QbzSession.trRev)
delegate: Rectangle {
id: choiceRow
required property var modelData
readonly property bool current:
root.releaseType === choiceRow.modelData.id
width: parent ? parent.width : 0
height: 28
radius: 4
color: choiceArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 8
// Always a 12x12 leading slot so the labels stay
// aligned (:157-171).
Item {
width: 12
height: parent.height
QbzIcon {
visible: choiceRow.current
anchors.centerIn: parent
name: "check"
width: 12
height: 12
tintName: "accent"
}
}
Text {
width: Math.max(0, parent.width - 20)
height: parent.height
text: choiceRow.modelData.label
color: choiceRow.current ? theme.accent : theme.textPrimary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: choiceArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
typeMenu.close()
QbzDisco.setTypeOverride(
root.candidate.source || "",
root.candidate.sourceItemId || "",
choiceRow.modelData.id)
}
}
}
}
// Reset rows — only while an override is active (:193-236):
// a 9px band carrying a 1px rule at y=4, then a 28px row.
Item {
visible: root.isOverridden
width: parent ? parent.width : 0
height: visible ? 9 : 0
Rectangle {
y: 4
width: parent.width
height: 1
color: theme.surfaceElevated
}
}
Rectangle {
visible: root.isOverridden
width: parent ? parent.width : 0
height: visible ? 28 : 0
radius: 4
color: resetArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 8
Item {
width: 12
height: parent.height
QbzIcon {
anchors.centerIn: parent
name: "rotate-ccw"
width: 12
height: 12
tintName: "muted"
}
}
Text {
width: Math.max(0, parent.width - 20)
height: parent.height
text: root.t("Use auto-detected")
color: theme.textMuted
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: resetArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
typeMenu.close()
QbzDisco.resetTypeOverride(
root.candidate.source || "",
root.candidate.sourceItemId || "")
}
}
}
}
}
// [3] 1fr — title (flexes) + the italic tag.
Item {
width: root.titleWidth
height: parent.height
Row {
anchors.fill: parent
spacing: 8
Item {
width: Math.max(0, parent.width
- (rowTag.visible ? rowTag.implicitWidth + 8 : 0))
height: parent.height
Text {
anchors.fill: parent
text: root.candidate.title || ""
color: titleArea.containsMouse
? theme.accent
: (root.alt ? theme.textMuted : theme.textPrimary)
font.pixelSize: root.alt ? 13 : 14
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: titleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzDisco.openAlbum(root.candidate.source || "",
root.candidate.sourceItemId || "")
}
}
// Alternates always show "alternate"; a primary shows
// "compilation" only when its GROUP is one (:358-375).
Text {
id: rowTag
visible: root.alt || root.groupIsCompilation
anchors.verticalCenter: parent.verticalCenter
text: root.alt ? root.t("alternate") : root.t("compilation")
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightMedium
font.italic: true
font.letterSpacing: 0.8
}
}
}
// [4] 72px — tracks (centred).
Text {
width: root.colTracks
height: parent.height
text: root.candidate.tracks || ""
color: root.alt ? theme.textMuted : theme.textSecondary
font.pixelSize: root.alt ? 12 : 13
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// [5] 60px — source glyph, centred in a 28x28 cell (`SourceIndicator`).
Item {
width: root.colSource
height: parent.height
Item {
anchors.centerIn: parent
width: 28
height: 28
SourceIcon {
anchors.centerIn: parent
kind: root.candidate.source || ""
}
}
}
// [6] 156px — quality badge (implicitWidth floors at 136, so it fits).
Item {
width: root.colQuality
height: parent.height
QualityBadgeFull {
anchors.centerIn: parent
tier: root.candidate.qualityTier || ""
detail: root.candidate.qualityDetail || ""
}
}
}
}
+151
View File
@@ -0,0 +1,151 @@
// MyQbzCard — the Mixtape/Collection index tile. ONE file for BOTH of
// MyQbzShared.slint's index widgets, because they render the same fields with
// the same affordance and differ only in shape:
// * default arm = `MyQbzCard` (:76-116) — the 208x296 grid card;
// * `compact` = `MyQbzListRow` (:120-163) — the 64px list row.
// Slint keeps them apart only because its layouts are declarative; folding
// them here is the same call MyQbzGridView.qml makes for the two views.
//
// Grid card: 208 wide x (184 + 12 + 12 + 18 + 2*20 + 18 + 12) = 296 tall, r8,
// bg hover -> surface-hover, border 1px that ALSO goes surface-hover on hover
// (:82-84); inner padding 12 all round, spacing 8, top-aligned: 184 mosaic, a
// 2px spacer, a 40px 2-line name clamp (so cards with a one-line and a two-line
// name still align), then the muted subtitle.
// List row: 64 tall, r6, bg hover -> surface-hover else TRANSPARENT, padding
// 12 left/right, spacing 12, a 48px mosaic and a 2px-spaced text column.
//
// Both are entirely `item.*` driven (the GridDoc `GridCard` object) and the
// WHOLE tile is the hit target with a pointer cursor. There is NO context menu
// on either shape in Slint and no hover animation is declared — do not add
// either.
//
// The two arms live behind a Loader because the mosaic is a Canvas with up to
// nine image probes: mounting both trees would double that per delegate.
import QtQuick
import com.blitzfc.qbz
import "../../cards"
import "../../theme"
Rectangle {
id: root
/// One GridDoc `GridCard`: id, name, kind, label, meta, itemCount,
/// hasCustomCover, customCoverPath, coverCount, cellUrls, cellPaths.
property var item: ({})
/// false = 208x296 grid card, true = 64px list row.
property bool compact: false
signal clicked()
QbzTheme { id: theme }
// implicitWidth (not width): the grid delegate wants the fixed 208 and the
// list delegate assigns the view's width over it.
implicitWidth: 208
height: root.compact ? 64 : (184 + 12 + 12 + 18 + 2 * 20 + 18 + 12)
radius: root.compact ? 6 : 8
color: cardArea.containsMouse ? theme.surfaceHover
: (root.compact ? "transparent" : theme.surfaceCard)
border.width: root.compact ? 0 : 1
border.color: cardArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
readonly property string subtitle: (root.item.label || "") + " · " + (root.item.meta || "")
Loader {
anchors.fill: parent
sourceComponent: root.compact ? rowBody : cardBody
}
// --- Grid card ------------------------------------------------------
Component {
id: cardBody
Column {
x: 12
y: 12
width: parent.width - 24
spacing: 8
CollectionMosaic {
size: 184
urls: root.item.cellUrls || []
paths: root.item.cellPaths || []
coverCount: root.item.coverCount || 0
kind: root.item.kind || ""
itemCount: root.item.itemCount || 0
hasCustomCover: root.item.hasCustomCover === true
customCoverPath: root.item.customCoverPath || ""
}
Item { width: 1; height: 2 }
Text {
width: parent.width
// 2 lines RESERVED, not "up to 2" — the clamp is what keeps a
// row of cards aligned (MyQbzShared.slint:102).
height: 40
text: root.item.name || ""
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
wrapMode: Text.WordWrap
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.subtitle
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
}
// --- List row -------------------------------------------------------
Component {
id: rowBody
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 12
CollectionMosaic {
anchors.verticalCenter: parent.verticalCenter
size: 48
urls: root.item.cellUrls || []
paths: root.item.cellPaths || []
coverCount: root.item.coverCount || 0
kind: root.item.kind || ""
itemCount: root.item.itemCount || 0
hasCustomCover: root.item.hasCustomCover === true
customCoverPath: root.item.customCoverPath || ""
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, parent.width - 48 - 12)
spacing: 2
Text {
width: parent.width
text: root.item.name || ""
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.subtitle
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
}
}
MouseArea {
id: cardArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
}
@@ -0,0 +1,165 @@
// MyQbzDetailCard — ONE grid card of the My QBZ detail body, the port of the
// local `DetailCard` component in myqbz/MixtapeDetailView.slint:495-594.
//
// Geometry 1:1 with the .slint: `height = cardW + 8 + 18 + 16 + 16`
// (= cardW + 58, :501), radius 8, padding 8, spacing 8, a square artwork of
// `cardW - 16` at radius 6, title 14px/medium, subtitle 12px/muted. Selected
// = a 2px accent border plus the `surface-hover` fill the hover uses.
//
// The host GridView owns the column arithmetic (spec 01 §7.4) and hands the
// width down as `cardW`, exactly as the .slint passes `card-w` (:498).
//
// TWO deviations from the .slint, both deliberate:
//
// * `cardArea` is declared FIRST and pinned `z: -1`. The .slint declares
// `card-ta` LAST (:584), i.e. frontmost, which puts it over the artwork's
// own play / checkbox TouchAreas — the identical defect
// `MixtapeDetailView.slint:203-217` describes and fixes for the ROW but
// never fixed here, so the card's hover scrim and its play affordance
// cannot fire upstream. Fixed on the way over.
// * `rev` is a dependency of every `item` read — the host patches the row
// objects IN PLACE and a plain JS object mutation fires no notifier. See
// the long note in MyQbzDetailRow.qml; the `>= 0` guard is always true
// and exists only to capture the dependency.
import QtQuick
import com.blitzfc.qbz
import "../../theme"
import "../local"
Rectangle {
id: root
/// One `DetailRow` object out of `detailJson.items` (spec 02 §5.2).
property var item: ({})
/// Card width, computed by the host from the auto-fill arithmetic.
property real cardW: 150
property bool selectMode: false
/// In-place-patch revision (see the header).
property int rev: 0
QbzTheme { id: theme }
readonly property int itemPosition: root.rev >= 0 ? (root.item.position || 0) : 0
readonly property string itemType: root.rev >= 0 ? (root.item.itemType || "") : ""
readonly property string itemSource: root.rev >= 0 ? (root.item.source || "") : ""
readonly property string sourceItemId: root.rev >= 0 ? (root.item.sourceItemId || "") : ""
readonly property string rowTitle: root.rev >= 0 ? (root.item.title || "") : ""
readonly property string rowSubtitle: root.rev >= 0 ? (root.item.subtitle || "") : ""
readonly property string artPath: root.rev >= 0 ? (root.item.artPath || "") : ""
readonly property bool selected: root.rev >= 0 && root.item.selected === true
/// track → music, playlist → list-music, else disc (.slint :522-526).
readonly property string typeGlyph: root.itemType === "track" ? "music"
: (root.itemType === "playlist" ? "list-music" : "disc")
readonly property bool hovered: cardArea.containsMouse || playArea.containsMouse
width: root.cardW
height: root.cardW + 58
radius: 8
color: (root.hovered || root.selected) ? theme.surfaceHover : "transparent"
border.width: root.selected ? 2 : 0
border.color: theme.accent
// Card-wide target — FIRST + `z: -1` (see the header).
MouseArea {
id: cardArea
anchors.fill: parent
z: -1
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.selectMode) QbzMyQbz.detailToggleItemSelect(root.itemPosition)
else QbzMyQbz.openItem(root.itemSource, root.itemType, root.sourceItemId)
}
}
Column {
x: 8
y: 8
width: parent.width - 16
spacing: 8
// --- Artwork ------------------------------------------------------
Rectangle {
width: parent.width
height: width
radius: 6
color: theme.surfaceElevated
clip: true
RoundedImage {
visible: root.artPath !== ""
anchors.fill: parent
source: root.artPath
radius: 6
}
QbzIcon {
visible: root.artPath === ""
anchors.centerIn: parent
name: root.typeGlyph
width: 28
height: 28
tintName: "muted"
}
// Select-mode: the checkbox rides a dark disc so it stays legible
// over a bright cover (.slint :536-549).
Rectangle {
visible: root.selectMode
x: 6
y: 6
width: 22
height: 22
radius: 11
color: root.selected ? "transparent" : "#8c000000"
SelectCheck {
anchors.centerIn: parent
on: root.selected
onToggled: QbzMyQbz.detailToggleItemSelect(root.itemPosition)
}
}
// Otherwise: the hover play scrim (.slint :550-566).
Rectangle {
visible: !root.selectMode
anchors.fill: parent
color: "#73000000"
opacity: playArea.containsMouse ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
QbzIcon {
anchors.centerIn: parent
name: "play-fill"
width: 24
height: 24
tintName: "white"
}
}
MouseArea {
id: playArea
anchors.fill: parent
enabled: !root.selectMode
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzMyQbz.playItem(root.sourceItemId)
}
}
Text {
width: parent.width
text: root.rowTitle
color: theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.rowSubtitle
color: theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
}
}
}
@@ -0,0 +1,401 @@
// MyQbzDetailRow — ONE item row of the My QBZ detail list, the port of the
// local `DetailRow` component in myqbz/MixtapeDetailView.slint:190-492.
//
// 8-column template, 1:1 with the .slint (:219-491): padding 12/12,
// spacing 12, cells 40 / 1fr / 140 / 80 / 160 / 72 / 60 / 40. Row height 56,
// bottom hairline `Theme.alpha-4`, hover/selected fill `surface-hover`.
//
// --- TWO THINGS THAT ARE LOAD-BEARING -----------------------------------
//
// 1. Z-ORDER. `MixtapeDetailView.slint:203-217` documents the bug: the
// row-wide click target MUST sit BEHIND the per-cell hit areas (checkbox,
// artwork play, title, subtitle, ⋯) or it eats every one of their clicks
// and the whole row just opens the album. In QML declaration order IS
// z-order, so `rowArea` is declared FIRST and additionally pinned `z: -1`
// — the same belt-and-braces `rows/TrackRow.qml:644-652` uses.
//
// 2. `rev` IS A REAL DEPENDENCY OF EVERY `item` READ. The host holds the
// model array with a stable identity and PATCHES the row objects in place
// when Rust republishes a single-row change (`resolve_items`,
// `ensure_expanded`, a select toggle) — replacing the array would push a
// new one into `model:` and `QQuickItemView::setModel()` resets contentY
// to 0 (the analysis at `views/LibraryView.qml:174-221`). A plain JS
// object mutation fires NO notifier (`rows/TrackRow.qml:128-151` measured
// both halves), so each read below is routed through a declared property
// whose binding also reads `root.rev`. The `root.rev >= 0` guard is
// ALWAYS true — it exists only to make `rev` a captured dependency. Do
// not "simplify" it away.
//
// The row calls `QbzMyQbz` directly, exactly as the .slint calls
// `MyQbzDetailActions` directly — no signal relay.
//
// Deliberate deviations, all four documented in spec 01 §7.8: the shared
// `controls/CardMenu.qml` replaces the .slint's local `RowMenuItem`
// (33/r5/15px-glyph/8px-pad vs 32/r6/13/12; label rests `textSecondary` not
// `text-primary`; the icon tint tracks hover; a separator is a 7px band with
// a 1px `borderSubtle` rule instead of a bare 1px `surface-elevated` line).
// TRACK-RULES §5 (reuse before duplicate) outranks those deltas.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
import "../local"
Rectangle {
id: root
/// One `DetailRow` object out of `detailJson.items` (spec 02 §5.2).
property var item: ({})
/// 1-based display ordinal among the VISIBLE rows (.slint `index`, :192).
/// Named `ordinal`, not `index`, so a delegate can pass `index + 1`
/// without shadowing the delegate's own context `index`.
property int ordinal: 0
property bool selectMode: false
/// The host's shared 3-phase clock for `QbzLoadingDots` (ONE timer per
/// view — spec 01 §7.5 / §15.5).
property int dotPhase: 0
/// In-place-patch revision. See note 2 in the header.
property int rev: 0
QbzTheme { id: theme }
// --- Every `item` read, made notifiable (header note 2) --------------
readonly property int itemPosition: root.rev >= 0 ? (root.item.position || 0) : 0
readonly property string itemType: root.rev >= 0 ? (root.item.itemType || "") : ""
readonly property string itemSource: root.rev >= 0 ? (root.item.source || "") : ""
readonly property string sourceItemId: root.rev >= 0 ? (root.item.sourceItemId || "") : ""
readonly property string rowTitle: root.rev >= 0 ? (root.item.title || "") : ""
readonly property string rowSubtitle: root.rev >= 0 ? (root.item.subtitle || "") : ""
readonly property bool subtitleIsLink: root.rev >= 0 && root.item.subtitleIsLink === true
readonly property string artistId: root.rev >= 0 ? (root.item.artistId || "") : ""
readonly property string sourceKind: root.rev >= 0 ? (root.item.sourceKind || "") : ""
readonly property string typeLabel: root.rev >= 0 ? (root.item.typeLabel || "") : ""
readonly property string qualityTier: root.rev >= 0 ? (root.item.qualityTier || "") : ""
readonly property string qualityDetail: root.rev >= 0 ? (root.item.qualityDetail || "") : ""
readonly property bool qualityResolving: root.rev >= 0 && root.item.qualityResolving === true
readonly property string tracksText: root.rev >= 0 ? (root.item.tracksText || "") : ""
readonly property string yearText: root.rev >= 0 ? (root.item.yearText || "") : ""
readonly property string artPath: root.rev >= 0 ? (root.item.artPath || "") : ""
readonly property bool selected: root.rev >= 0 && root.item.selected === true
/// track → music, playlist → list-music, else disc (.slint :263-267).
readonly property string typeGlyph: root.itemType === "track" ? "music"
: (root.itemType === "playlist" ? "list-music" : "disc")
// Hover: OR'd across the cell areas because a child MouseArea with
// `hoverEnabled` steals `containsMouse` from the row's own area — the
// same reason `rows/TrackRow.qml:213-214` OR's its four.
readonly property bool hovered: rowArea.containsMouse || artArea.containsMouse
|| titleArea.containsMouse || subArea.containsMouse || menuArea.containsMouse
width: parent ? parent.width : 0
height: 56
color: (root.hovered || root.selected) ? theme.surfaceHover : "transparent"
/// Title column = rowWidth − padding(24) − fixed cells(592) − gaps(84).
readonly property int titleColWidth: Math.max(0, root.width - 700)
// Bottom hairline — `#ffffff0a` in the .slint (:200) == `alpha-4`, taken
// from the polarity-baked ramp so it is visible on light themes too. The
// `.length` guard covers the pre-publish frame QbzTheme documents.
Rectangle {
y: parent.height - 1
width: parent.width
height: 1
color: theme.alphaTiers.length > 0 ? theme.alphaTier(4)
: (theme.isDark ? "#0affffff" : "#0a000000")
}
// Row-wide target — FIRST + `z: -1` (header note 1). Right press opens
// the SAME menu at the pointer, the port's standing rule
// (`controls/QbzContextMenu.qml:20-22`); the .slint has no right-click
// here, so this is an additive port convention kept consistent with
// `rows/TrackRow.qml`.
MouseArea {
id: rowArea
anchors.fill: parent
z: -1
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onPressed: function (mouse) {
if (mouse.button === Qt.RightButton) { rowMenu.openAtCursor(rowArea, mouse.x, mouse.y); mouse.accepted = true }
}
onClicked: function (mouse) {
if (mouse.button !== Qt.LeftButton) return
if (root.selectMode) QbzMyQbz.detailToggleItemSelect(root.itemPosition)
else QbzMyQbz.openItem(root.itemSource, root.itemType, root.sourceItemId)
}
}
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 12
// --- Col 1 (40) — ordinal / selection checkbox ------------------
Item {
width: 40
height: parent.height
Text {
visible: !root.selectMode
anchors.centerIn: parent
text: root.ordinal
color: theme.textMuted
font.pixelSize: 13
}
SelectCheck {
visible: root.selectMode
anchors.centerIn: parent
on: root.selected
onToggled: QbzMyQbz.detailToggleItemSelect(root.itemPosition)
}
}
// --- Col 2 (1fr) — artwork + title / subtitle -------------------
Row {
width: root.titleColWidth
height: parent.height
spacing: 12
Rectangle {
width: 36
height: 36
anchors.verticalCenter: parent.verticalCenter
radius: 4
color: theme.surfaceElevated
clip: true
RoundedImage {
visible: root.artPath !== ""
anchors.fill: parent
source: root.artPath
radius: 4
}
QbzIcon {
visible: root.artPath === ""
anchors.centerIn: parent
name: root.typeGlyph
width: 18
height: 18
tintName: "muted"
}
// Hover play overlay (.slint :275-291).
Rectangle {
anchors.fill: parent
color: "#99000000"
opacity: artArea.containsMouse ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
QbzIcon {
anchors.centerIn: parent
name: "play-fill"
width: 16
height: 16
tintName: "white"
}
}
MouseArea {
id: artArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzMyQbz.playItem(root.sourceItemId)
}
}
Column {
width: Math.max(0, parent.width - 48)
anchors.verticalCenter: parent.verticalCenter
spacing: 2
// Title — its OWN 18px hit area (.slint :298-313).
Item {
width: parent.width
height: 18
Text {
anchors.fill: parent
text: root.rowTitle
color: titleArea.containsMouse ? theme.accent : theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightMedium
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: titleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzMyQbz.openItem(root.itemSource, root.itemType, root.sourceItemId)
}
}
// Subtitle — 16px hit area, pointer only when it is a link.
Item {
visible: root.rowSubtitle !== ""
width: parent.width
height: 16
Text {
anchors.fill: parent
text: root.rowSubtitle
color: (root.subtitleIsLink && subArea.containsMouse)
? theme.accent : theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: subArea
anchors.fill: parent
hoverEnabled: true
cursorShape: root.subtitleIsLink ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (root.subtitleIsLink)
QbzMyQbz.openArtist(root.itemSource, root.rowSubtitle, root.artistId)
}
}
}
}
}
// --- Col 3 (140) — type ----------------------------------------
Item {
width: 140
height: parent.height
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: 6
QbzIcon {
name: root.typeGlyph
width: 13
height: 13
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
Text {
text: root.typeLabel
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.2
anchors.verticalCenter: parent.verticalCenter
}
}
}
// --- Col 4 (80) — source glyph (the `!== ""` gate is the .slint's,
// :370) ----------------------------------------------------------
Item {
width: 80
height: parent.height
SourceIcon {
visible: root.sourceKind !== ""
x: 0
anchors.verticalCenter: parent.verticalCenter
kind: root.sourceKind
}
}
// --- Col 5 (160) — quality ---------------------------------------
Item {
width: 160
height: parent.height
QualityBadgeFull {
visible: root.qualityTier !== ""
x: 0
anchors.verticalCenter: parent.verticalCenter
tier: root.qualityTier
detail: root.qualityDetail
}
// The .slint's `LoadingDots` inherits Rectangle, so it FILLS this
// 160px cell and centres its dot row in it (LoadingDots.slint
// :26-35) — hence `anchors.fill`, not `x: 0`.
QbzLoadingDots {
visible: root.qualityTier === "" && root.qualityResolving
anchors.fill: parent
phase: root.dotPhase
}
}
// --- Col 6 (72) — tracks, right-aligned --------------------------
Text {
width: 72
height: parent.height
text: root.tracksText
color: theme.textSecondary
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignRight
verticalAlignment: Text.AlignVCenter
}
// --- Col 7 (60) — year, right-aligned ----------------------------
Text {
width: 60
height: parent.height
text: root.yearText
color: theme.textSecondary
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignRight
verticalAlignment: Text.AlignVCenter
}
// --- Col 8 (40) — ⋯ menu -----------------------------------------
Item {
width: 40
height: parent.height
Rectangle {
id: menuBtn
x: parent.width - width
width: 24
height: 24
anchors.verticalCenter: parent.verticalCenter
radius: 4
color: menuArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
anchors.centerIn: parent
name: "ellipsis"
width: 14
height: 14
tintName: menuArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: menuArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// Slint anchors the 184px panel `x: btn.width - 184;
// y: 28` on this 24px button (:438-439) — which is exactly
// what `openBelowRight` computes (24 + 4 = 28).
onClicked: rowMenu.openBelowRight(menuBtn)
}
}
}
}
// Row ⋯ menu (.slint :437-488), 184px, in the .slint's order.
CardMenu {
id: rowMenu
menuWidth: 184
entries: root.rowMenuModel()
onPicked: function (a) { root.rowMenuAction(a) }
}
function rowMenuModel() {
var t = QbzSession.tr
var r = QbzSession.trRev
return [
{ "label": t("Play", r), "icon": "play-fill", "action": "play" },
{ "label": t("Play next", r), "icon": "list-start", "action": "play-next" },
{ "label": t("Play later", r), "icon": "list-plus", "action": "play-later" },
{ "label": t("Add to queue", r), "icon": "list-end", "action": "add-to-queue" },
{ "sep": true },
{ "label": t("Remove from collection", r), "icon": "trash-2",
"action": "remove", "danger": true },
]
}
function rowMenuAction(a) {
if (a === "play") QbzMyQbz.playItem(root.sourceItemId)
else if (a === "remove") QbzMyQbz.removeItem(root.itemPosition)
else QbzMyQbz.itemAction(root.sourceItemId, a)
}
}
@@ -0,0 +1,902 @@
// MyQbzDetailView — the My QBZ Mixtape / Collection DETAIL route
// ("mixtapedetail"), the port of myqbz/MixtapeDetailView.slint:596-1272.
// Hero + sticky toolbar + body (list / grid / expanded) + the bulk bar.
// ONE document: `QbzMyQbz.detailJson` (spec 02 §5.2 `DetailDoc`).
//
// No per-view back button — the shell owns the global < > nav (.slint :613).
//
// --- PAGE FRAME, and the ONE structural deviation ------------------------
// The .slint puts hero + toolbar + body inside a single Flickable
// (:631-660: padding 16 / 32 / 100 / 32, spacing 0, top-aligned), so its hero
// scrolls away. Here the hero + toolbar are a FIXED header and only the body
// scrolls, which is the port's established shape for a WINDOWED body
// (`views/LibraryView.qml:1269`, `views/local/LocalTracksTab.qml:135`).
//
// Why, since 1:1 is the bar: an artist_collection holds 200+ items and every
// row carries a QualityBadgeFull, so the body MUST be a recycling
// ListView/GridView (spec 01 §15.1, non-negotiable). A recycling view cannot
// be content-sized inside an outer Flickable, and `ListView.header` is not
// available to the GRID arm either — the grid is hosted in a clipped Item and
// given `width: parent.width + 20 - 24` to reproduce Slint's column count
// (spec 01 §7.4), which would offset and over-widen a header inside it. The
// paddings, the 32px hero→body gap and the 10px toolbar tail are all kept;
// the toolbar simply became literally sticky, which is what §7.2 calls it.
//
// --- THE MODEL IS PATCHED, NEVER REPLACED, AND PARSED ONCE --------------
// `QbzMyQbz` carries no signals: every Rust-side change arrives as a whole new
// `detailJson`. `refresh()` is the single entry point: ONE `JSON.parse` per
// republish (spec 01 §15.2), feeding both `doc` and `syncRows` from the same
// object so the two can never be a republish out of step.
// A naive `model: JSON.parse(...).items` would push a NEW
// array into the view on every `resolve_items` badge, every `ensure_expanded`
// batch and every checkbox — and `QQuickItemView::setModel()` resets contentY
// to 0 (and can SIGSEGV from inside its own teardown; the analysis is at
// `views/LibraryView.qml:174-221`). So `syncRows()` keeps the array identity
// whenever the DERIVATION did not change (same collection, same positions in
// the same order) and patches the row objects in place, bumping `patchRev` —
// the notifier a plain JS object mutation does not have. The delegates read
// `patchRev` (see MyQbzDetailRow.qml's header). The array is replaced only on
// a real re-derive (sort / filter / search / another collection), which is
// exactly when Slint also rebuilds and the scroll reset is correct.
//
// Reused, not rebuilt: rows/TrackRow.qml (inline tracks),
// controls/QualityBadgeFull, controls/CardMenu, controls/QbzContextMenu,
// controls/QbzSegToggle, controls/QbzMultiSelectBar, controls/QbzToolButton,
// controls/QbzLoadingDots, controls/QbzLineEdit, controls/QbzSelect,
// controls/QbzCircleAction, cards/CollectionMosaic, views/local/SelectCheck,
// theme/QbzScrollBar, theme/QbzSpinner.
//
// `controls/QbzLineEdit.qml` is NOT 1:1 with `primitives/ExpandableSearch.slint`
// — five pre-existing, app-wide divergences (leading glyph 13 vs 14 at `sm`,
// input + placeholder 12px vs 15, row spacing 7 vs 8, focus border `accent`
// vs `focus-ring`, X hover `surfaceHover` vs `surface-elevated`). Shipped as
// they are: fixing them changes the Local Library toolbars too (spec 01 §3.2).
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../../cards"
import "../../controls"
import "../../rows"
import "../../theme"
import "../local"
Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
radius: 12
QbzTheme { id: theme }
function trs(s) { return QbzSession.tr(s, QbzSession.trRev) }
// ---------------------- file-private components ----------------------
/// One 10px column label of the 8-column header (.slint :1123-1129).
component ColHead: Text {
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.2
height: 34
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
/// A filter-popup section header (.slint :968-978): 20px, 10px semibold,
/// letter-spacing 0.8, left-aligned.
component SectionHead: Text {
width: parent ? parent.width : 0
height: 20
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 0.8
verticalAlignment: Text.AlignVCenter
}
/// One filter row (.slint `MenuOption`, :47-77): 30px, r4, hover
/// `surface-hover`, padding 8, spacing 8, a leading `SelectCheck` and a
/// 13px label that goes accent when selected. The WHOLE row is clickable,
/// not just the box.
component MenuOption: Rectangle {
id: mo
property string label: ""
property bool selected: false
signal chosen()
width: parent ? parent.width : 0
height: 30
radius: 4
color: moArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 8
SelectCheck {
anchors.verticalCenter: parent.verticalCenter
on: mo.selected
onToggled: mo.chosen()
}
Text {
width: Math.max(0, parent.width - 21)
height: parent.height
text: mo.label
color: mo.selected ? theme.accent : theme.textPrimary
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: moArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: mo.chosen()
}
}
// ------------------------------ document ------------------------------
function parseDoc() {
try {
return JSON.parse(QbzMyQbz.detailJson)
} catch (e) {
return ({})
}
}
/// ONE parse per republish (spec 01 §15.2). `refresh()` is the only writer
/// and the only caller of `syncRows`, so `doc` and `rows` can never be a
/// republish out of step — the earlier shape had a `readonly` binding here
/// AND a second `parseDoc()` inside the change handler, i.e. two parses of
/// a document that in expanded mode carries every item's inline track list.
/// The initial value is still a BINDING so the very first frame is already
/// correct (a plain `({})` would flash the not-found state for a frame);
/// the first `refresh()` assignment replaces it.
property var doc: root.parseDoc()
readonly property bool loading: doc.loading === true
readonly property bool found: doc.found === true
readonly property string kind: doc.kind || ""
readonly property int itemCount: doc.itemCount || 0
readonly property string viewMode: doc.viewMode || "list"
readonly property bool selectMode: doc.selectMode === true
readonly property int selectedCount: doc.selectedCount || 0
readonly property int filterCount: doc.filterCount || 0
readonly property bool hasAnyFilter: doc.hasAnyFilter === true
// --------------------------- the row model -----------------------------
property var rows: []
property string rowsKey: ""
property int patchRev: 0
/// Identity of the current derivation: the collection plus the visible
/// positions in order. `position` is the stable persisted key (spec 02
/// §5.2), so this changes exactly when Slint would rebuild its model.
function rowsSignature(d) {
var it = d.items || []
var s = (d.id || "") + "#" + it.length
for (var i = 0; i < it.length; i++) s += "," + it[i].position
return s
}
function syncRows(d) {
var fresh = d.items || []
var sig = root.rowsSignature(d)
if (sig === root.rowsKey && root.rows.length === fresh.length) {
for (var i = 0; i < fresh.length; i++) Object.assign(root.rows[i], fresh[i])
root.patchRev = root.patchRev + 1
return
}
root.rows = fresh
root.rowsKey = sig
root.patchRev = root.patchRev + 1
// `ensureExpanded` is idempotent (cached rows are skipped). Rust fires
// it from `detailSetViewMode("expanded")`; the OTHER entry is an
// INITIAL OPEN whose restored view-pref is already `expanded`
// (spec 01 §7.5), which no invokable covers. Read off `d`, not the
// `doc` binding, which may not have re-evaluated yet.
if (d.loading !== true && d.found === true && d.viewMode === "expanded"
&& root.expandedFor !== (d.id || "")) {
root.expandedFor = d.id || ""
QbzMyQbz.ensureExpanded()
}
}
property string expandedFor: ""
/// The single republish entry point: parse once, publish `doc`, then patch
/// or replace `rows` off THAT same object. Never read `root.doc` here — the
/// order between a binding on `doc` and this handler is not defined, which
/// is why the parse happens locally and is handed to both.
function refresh() {
var d = root.parseDoc()
root.doc = d
root.syncRows(d)
}
Connections {
target: QbzMyQbz
function onDetailJsonChanged() { root.refresh() }
}
// `doc`'s initial binding has already parsed at this point — reuse it
// instead of parsing a third time.
Component.onCompleted: root.syncRows(root.doc)
// --- ONE loading-dots clock for the whole view (spec 01 §7.5 / §15.5) --
// Slint animates each row's LoadingDots off `animation-tick`; a windowed
// list of hundreds of rows must not. Gating rule, copied from
// `views/PlaylistView.qml:156-179`: freeze when not visible or the window
// is minimized / hidden, NEVER on lost focus (a tiling desktop keeps
// windows visible and unfocused).
property int dotPhase: 0
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
readonly property bool anyResolving: {
if (root.patchRev < 0) return false // `patchRev` dependency
var r = root.rows
for (var i = 0; i < r.length; i++) {
if (r[i].qualityResolving === true && (r[i].qualityTier || "") === "") return true
}
return false
}
Timer {
interval: 300
repeat: true
running: root.anyResolving && root.visible && root.windowShowing
onTriggered: root.dotPhase = (root.dotPhase + 1) % 3
}
// ============================== LOADING ==============================
QbzSpinner {
visible: root.loading
anchors.centerIn: parent
size: 36
}
// ============================= NOT FOUND =============================
Text {
visible: !root.loading && !root.found
anchors.centerIn: parent
text: root.trs("Not found")
color: theme.textMuted
font.pixelSize: theme.fontLink
}
// =============================== LOADED ==============================
Item {
id: page
visible: !root.loading && root.found
anchors.fill: parent
// --------------------------- fixed header -------------------------
Column {
id: headerCol
x: 32
y: 16
width: parent.width - 64
spacing: 0
// --- Hero (.slint :663-861) -------------------------------
Row {
width: parent.width
spacing: 32
// Rust pre-downscales the hero cells (3 cols -> _50, else
// _150 — `myqbz_detail.rs:375`), so the mosaic only lays out.
CollectionMosaic {
anchors.bottom: parent.bottom
size: 186
kind: root.kind
itemCount: root.itemCount
hasCustomCover: root.doc.hasCustomCover === true
customCoverPath: root.doc.customCoverPath || ""
coverCount: root.doc.coverCount || 0
urls: root.doc.heroCellUrls || []
paths: root.doc.heroCellPaths || []
}
Column {
width: Math.max(0, parent.width - 186 - 32)
anchors.bottom: parent.bottom
spacing: 8
Text {
text: root.doc.kindLabel || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
font.letterSpacing: 1.2
}
// 18px, NOT 25 — .slint :709-713 calls the old 25px a
// homologation bug against the other detail heroes.
Text {
width: parent.width
text: root.doc.name || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
wrapMode: Text.WordWrap
}
Text {
visible: (root.doc.description || "") !== ""
width: Math.min(root.width - 280, 720)
text: root.doc.description || ""
color: theme.textSecondary
font.pixelSize: theme.fontLink
wrapMode: Text.WordWrap
}
Text {
text: root.doc.meta || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
Item { width: 1; height: 4 }
// EXACTLY four controls (.slint :735-859).
Row {
spacing: 8
QbzCircleAction {
name: "play-fill"
primary: true
btnEnabled: root.itemCount > 0
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMyQbz.playAll()
}
QbzCircleAction {
name: "shuffle"
btnEnabled: root.itemCount > 0
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMyQbz.shuffle()
}
// ASSET GAP: `turntable` is not baked yet (spec 01 §14).
QbzCircleAction {
name: "turntable"
btnEnabled: root.itemCount > 0
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMyQbz.djMix()
}
Item {
id: overflowAnchor
width: 32
height: 32
anchors.verticalCenter: parent.verticalCenter
// Self-sizing (32px, the non-primary arm), so the
// anchor Item is exactly its box.
QbzCircleAction {
name: "ellipsis"
// Slint anchors this 220px panel `x: 0,
// y: 36` on the 32px disc (:787-788), so it
// hugs the trigger's LEFT edge —
// `openBelowRight` would throw it 188px left.
onClicked: heroMenu.openBelowLeft(overflowAnchor)
}
}
}
}
}
Item { width: 1; height: 32 }
// --- Empty collection (.slint :866-877) — the hero stays, the
// toolbar and the body do not (:880) ----------------------------
Item {
visible: root.itemCount === 0
width: parent.width
height: visible ? 80 : 0
Text {
anchors.centerIn: parent
text: root.trs("No items yet. Add albums, tracks, or playlists from their detail pages.")
color: theme.textMuted
font.pixelSize: 13
}
}
// --- Sticky toolbar (.slint :885-1086) ------------------------
Item {
visible: root.itemCount > 0
width: parent.width
height: visible ? 30 : 0
Row {
anchors.right: parent.right
height: parent.height
spacing: 8
QbzLineEdit {
anchors.verticalCenter: parent.verticalCenter
searchMode: true
expandable: true
sm: true
elevated: false
openWidth: 196
placeholder: root.trs("Search")
text: root.doc.search || ""
onEdited: function (v) { QbzMyQbz.detailSearch(v) }
}
QbzSelect {
anchors.verticalCenter: parent.verticalCenter
sm: true
menuWidth: 170
options: [root.trs("Position"), root.trs("Name"),
root.trs("Year"), root.trs("Tracks")]
currentIndex: (root.doc.sort || "position") === "name" ? 1
: (root.doc.sort || "position") === "year" ? 2
: (root.doc.sort || "position") === "tracks" ? 3 : 0
onSelected: function (i) {
QbzMyQbz.detailSetSort(i === 1 ? "name" : i === 2 ? "year"
: i === 3 ? "tracks" : "position")
}
}
// Filter — the LABELED arm, so `fillActive` (accent FILL).
QbzToolButton {
id: filterBtn
anchors.verticalCenter: parent.verticalCenter
name: "list-filter"
label: root.filterCount > 0
? (root.filterCount === 1 ? root.trs("1 filter")
: root.trs("{} filters").replace("{}", root.filterCount))
: root.trs("Filter")
active: root.filterCount > 0
fillActive: true
onClicked: filterMenu.openBelowLeft(filterBtn)
}
// Reset — icon-only, so accent BORDER, never a fill.
QbzToolButton {
visible: root.hasAnyFilter
anchors.verticalCenter: parent.verticalCenter
name: "rotate-ccw"
fillActive: false
onClicked: QbzMyQbz.detailResetFilters()
}
QbzToolButton {
anchors.verticalCenter: parent.verticalCenter
name: "square-check-big"
active: root.selectMode
fillActive: false
onClicked: QbzMyQbz.detailToggleSelectMode()
}
QbzSegToggle {
anchors.verticalCenter: parent.verticalCenter
width: 90
mode: root.viewMode
segments: [{ "id": "list", "icon": "list" },
{ "id": "grid", "icon": "layout-grid" },
{ "id": "expanded", "icon": "rows-3" }]
// The 3-segment ViewToggle metric set, NOT the control's
// Local defaults: MixtapeDetailView.slint:1058-1084 is a
// 90x30 r6 surface-elevated well holding THREE
// `ToggleButton { sm }` edge to edge — 30x30 each, no
// gap, 15px glyph, active fill surface-hover (never
// accent), hover surface-elevated, tint text-primary /
// text-muted (ToggleButton.slint:19-20,24-28,32-33,38).
// `segRadius` 6 rather than the reference's 0 because a
// QML clip is rectangular — see QbzSegToggle.qml.
segWidth: 30
segHeight: 30
segRadius: 6
segSpacing: 0
glyphSize: 15
activeFill: theme.surfaceHover
hoverFill: theme.surfaceElevated
activeTint: "textPrimary"
idleTint: "muted"
onSetMode: function (m) { QbzMyQbz.detailSetViewMode(m) }
}
}
}
Item { visible: root.itemCount > 0; width: 1; height: visible ? 10 : 0 }
}
// ----------------------------- the body ----------------------------
Item {
id: body
visible: root.itemCount > 0
anchors.top: headerCol.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: 32
anchors.rightMargin: 32
anchors.bottomMargin: 100
// ================= BODY: LIST / EXPANDED =================
Column {
id: listHost
anchors.fill: parent
visible: root.viewMode !== "grid"
spacing: 0
Rectangle { width: parent.width; height: 1; color: theme.surfaceElevated }
// Column header (.slint :1114-1135) — the 8th cell is a bare
// 40px spacer, not a label (:1130).
Item {
width: parent.width
height: 34
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 12
ColHead { width: 40; text: "#"; horizontalAlignment: Text.AlignHCenter }
ColHead {
width: Math.max(0, listHost.width - 700)
text: root.trs("Item")
}
ColHead { width: 140; text: root.trs("Type") }
ColHead { width: 80; text: root.trs("Source") }
ColHead { width: 160; text: root.trs("Quality") }
ColHead { width: 72; text: root.trs("Tracks"); horizontalAlignment: Text.AlignRight }
ColHead { width: 60; text: root.trs("Year"); horizontalAlignment: Text.AlignRight }
Item { width: 40; height: 1 }
}
}
Rectangle { width: parent.width; height: 1; color: theme.surfaceElevated }
Item {
width: parent.width
height: Math.max(0, listHost.height - 36)
ListView {
id: itemList
anchors.fill: parent
clip: true
boundsBehavior: Flickable.StopAtBounds
cacheBuffer: 500
reuseItems: true
model: root.rows
// EXPANDED mode is this SAME recycled list with the
// inline block unhidden — a nested ListView inside a
// recycled delegate cannot size itself (spec 02 §9.1),
// and switching the delegate per mode would reset
// contentY.
delegate: Column {
id: rowCell
required property var modelData
required property int index
width: itemList.width
spacing: 0
readonly property bool expanded: root.viewMode === "expanded"
&& (root.patchRev >= 0 && modelData.canExpand === true)
readonly property var inlineTracks: (root.patchRev >= 0)
? (modelData.inlineTracks || []) : []
readonly property bool expandLoading: root.patchRev >= 0
&& modelData.expandLoading === true
readonly property bool tracksLoaded: root.patchRev >= 0
&& modelData.tracksLoaded === true
MyQbzDetailRow {
width: parent.width
item: rowCell.modelData
ordinal: rowCell.index + 1
selectMode: root.selectMode
dotPhase: root.dotPhase
rev: root.patchRev
}
// Inline tracks (.slint :1151-1218): padding
// 52 / 12 / 4 / 8, spacing 0.
Item {
visible: rowCell.expanded
width: parent.width
height: visible ? inlineCol.height + 12 : 0
Column {
id: inlineCol
x: 52
y: 4
width: Math.max(0, parent.width - 64)
spacing: 0
Item {
visible: rowCell.expandLoading
&& rowCell.inlineTracks.length === 0
width: parent.width
height: visible ? 32 : 0
QbzSpinner { anchors.centerIn: parent; size: 16 }
}
Item {
visible: !rowCell.expandLoading && rowCell.tracksLoaded
&& rowCell.inlineTracks.length === 0
width: parent.width
height: visible ? 32 : 0
Text {
anchors.centerIn: parent
text: root.trs("No results found")
color: theme.textMuted
font.pixelSize: 12
}
}
Repeater {
model: rowCell.inlineTracks
delegate: TrackRow {
required property var modelData
required property int index
width: inlineCol.width
item: modelData
// `number` is a DELEGATE property,
// never an item field (spec 02 §5.2).
number: index + 1
showArtwork: false
showFavorite: false
showDownload: false
showMenu: true
// `showAlbum` is LEFT AT ITS DEFAULT
// (false) — TrackRow.slint:43 does the
// same and MixtapeDetailView does not
// override it, so there is no album
// LINK on these rows.
draggable: false
menuShowRemove: false
// `primitives/TrackRow.slint:125`
// zebras UNCONDITIONALLY on odd
// `index` (there is no `zebra`
// property to turn it off), so the
// inline block IS striped in the
// reference. Qt gates it and
// defaults false; `number % 2 === 0`
// with `number = index + 1` stripes
// exactly the same rows.
zebra: true
// Go-to must route through the PARENT
// item, not the track's own ids —
// spec 01 §7.5 / OQ#9.
routeGoToExternally: true
onPlayRequested: QbzMyQbz.inlineTrackAction(
rowCell.modelData.sourceItemId || "",
modelData.id || "", "play")
// `next|later|queue` -> the wire's
// `play-next|play-later|queue`.
// Unmapped is a SILENT no-op.
onEnqueueRequested: function (mode) {
QbzMyQbz.inlineTrackAction(
rowCell.modelData.sourceItemId || "",
modelData.id || "",
mode === "next" ? "play-next"
: mode === "later" ? "play-later" : "queue")
}
onGoToRequested: function (goKind) {
if (goKind === "artist")
QbzMyQbz.openArtist(
rowCell.modelData.source || "",
modelData.artist || "",
modelData.artistId || "")
else
QbzMyQbz.inlineTrackAction(
rowCell.modelData.sourceItemId || "",
modelData.id || "", "go-to-album")
}
}
}
}
}
}
}
}
}
// ======================= BODY: GRID =======================
// `auto-fill minmax(150, 1fr)`, gap 20, 12px content inset
// (.slint :1090-1108). The clipped host + `width: parent.width +
// 20 - 24` reproduces Slint's `floor((W - 24 + gap) / (150 + gap))`
// column count, which GridView's own `floor(W / cellWidth)` would
// otherwise undercount by one at every exact boundary.
Item {
id: gridHost
anchors.fill: parent
visible: root.viewMode === "grid"
clip: true
readonly property int columns: Math.max(1,
Math.floor((width - 24 + 20) / (150 + 20)))
// Clamped: the first frame has width 0, and a negative
// cellWidth is a silent GridView layout trap.
readonly property real cardW: Math.max(1,
(width - 24 - 20 * (columns - 1)) / columns)
readonly property real cardH: cardW + 58
GridView {
id: itemGrid
x: 12
y: 0
width: Math.max(0, gridHost.width + 20 - 24)
height: gridHost.height
cellWidth: gridHost.cardW + 20
cellHeight: gridHost.cardH + 20
cacheBuffer: 500
reuseItems: true
boundsBehavior: Flickable.StopAtBounds
model: root.rows
delegate: MyQbzDetailCard {
required property var modelData
item: modelData
cardW: gridHost.cardW
selectMode: root.selectMode
rev: root.patchRev
}
}
}
}
// The .slint floats its ListScrollbar at `parent.width - 14 - 4`
// (:1226-1235), i.e. INSIDE the page's 32px right padding. Mounted
// here, outside `body`, because the grid host clips.
QbzScrollBar {
target: itemList
visible: root.itemCount > 0 && root.viewMode !== "grid"
&& itemList.contentHeight > itemList.height
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: body.top
anchors.bottom: body.bottom
}
QbzScrollBar {
target: itemGrid
visible: root.itemCount > 0 && root.viewMode === "grid"
&& itemGrid.contentHeight > itemGrid.height
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: body.top
anchors.bottom: body.bottom
}
}
// ========================= BULK ACTION BAR ==========================
// A SIBLING of the scroller, floating over the body's 100px bottom
// gutter. `parent.width - 18 - 22` is asymmetric on purpose (.slint :1253).
QbzMultiSelectBar {
visible: root.selectMode && root.selectedCount > 0
x: 18
y: parent.height - height - 12
width: parent.width - 40
selectedCount: root.selectedCount
actions: root.bulkActions()
onAction: function (id) { QbzMyQbz.bulkAction(id) }
}
// Order is the .slint's (:1258-1266) and it puts LATER before NEXT — the
// opposite of the row menu. Do not "fix" it.
function bulkActions() {
var t = QbzSession.tr
var r = QbzSession.trRev
return [
{ "id": "add-to-queue", "label": t("Add to queue", r), "icon": "list-end",
"danger": false, "needsSelection": true },
{ "id": "play-later", "label": t("Play later", r), "icon": "list-plus",
"danger": false, "needsSelection": true },
{ "id": "play-next", "label": t("Play next", r), "icon": "list-start",
"danger": false, "needsSelection": true },
{ "id": "add-to-playlist", "label": t("Add to playlist", r), "icon": "list-music",
"danger": false, "needsSelection": true },
{ "id": "add-to-mixtape", "label": t("Add to Mixtape/Collection", r),
"icon": "cassette-tape", "danger": false, "needsSelection": true },
{ "id": "remove-selected", "label": t("Remove", r), "icon": "trash-2",
"danger": true, "needsSelection": true },
{ "id": "clear", "label": t("Clear", r), "icon": "x",
"danger": false, "needsSelection": true },
]
}
// ========================== HERO ⋯ MENU =============================
CardMenu {
id: heroMenu
menuWidth: 220
entries: root.heroMenuModel()
onPicked: function (a) { root.heroMenuAction(a) }
}
function heroMenuModel() {
var t = QbzSession.tr
var r = QbzSession.trRev
// ASSET GAP: `image` is not baked yet (spec 01 §14).
var m = [
{ "label": t("Rename", r), "icon": "pen-line", "action": "rename" },
{ "label": t("Edit description", r), "icon": "pen-line", "action": "description" },
{ "label": t("Upload cover", r), "icon": "image", "action": "upload-cover" },
]
if (root.doc.hasCustomCover === true)
m.push({ "label": t("Clear custom cover", r), "icon": "x", "action": "clear-cover" })
m.push({ "sep": true })
// The label is the OTHER mode (.slint :831-837).
m.push({ "label": (root.doc.playMode || "in_order") === "in_order"
? t("Album shuffle", r) : t("In order", r),
"icon": "shuffle", "action": "play-mode" })
if (root.kind !== "artist_collection")
m.push({ "label": root.kind === "mixtape"
? t("Convert to Collection", r) : t("Convert to Mixtape", r),
"icon": "cassette-tape", "action": "convert" })
m.push({ "sep": true })
m.push({ "label": t("Delete", r), "icon": "trash-2", "action": "delete", "danger": true })
return m
}
function heroMenuAction(a) {
if (a === "rename") QbzMyQbz.openRename()
else if (a === "description") QbzMyQbz.openDescription()
else if (a === "upload-cover") QbzMyQbz.uploadCover()
else if (a === "clear-cover") QbzMyQbz.removeCover()
else if (a === "play-mode") QbzMyQbz.togglePlayMode()
else if (a === "convert") QbzMyQbz.convertKind()
else if (a === "delete") QbzMyQbz.openDelete()
}
// ========================= FILTER POPUP =============================
// `QbzContextMenu` HARDCODES padding 5 / `surfaceMain` / r8 /
// `borderMuted` and its content Column's spacing is unreachable through
// the `menuContent` alias, so the panel, the padding and the row spacing
// are all written here (spec 01 §3.3). Its default
// `CloseOnPressOutside` is what the .slint asks for (:952-956): these are
// radio / checkbox rows that must survive several toggles.
QbzContextMenu {
id: filterMenu
menuWidth: 200
padding: 4
background: Rectangle {
color: theme.surfaceMain
radius: 8
border.width: 1
border.color: theme.surfaceElevated
}
Column {
width: parent ? parent.width : 0
spacing: 1
SectionHead { text: root.trs("Type") }
MenuOption {
label: root.trs("All types")
selected: (root.doc.typeFilter || "all") === "all"
onChosen: QbzMyQbz.detailSetTypeFilter("all")
}
MenuOption {
label: root.trs("Album")
selected: root.doc.typeFilter === "album"
onChosen: QbzMyQbz.detailSetTypeFilter("album")
}
MenuOption {
label: root.trs("Track")
selected: root.doc.typeFilter === "track"
onChosen: QbzMyQbz.detailSetTypeFilter("track")
}
MenuOption {
label: root.trs("Playlist")
selected: root.doc.typeFilter === "playlist"
onChosen: QbzMyQbz.detailSetTypeFilter("playlist")
}
Rectangle {
width: parent.width
height: 1
color: theme.surfaceElevated
}
SectionHead { text: root.trs("Source") }
MenuOption {
label: root.trs("Qobuz")
selected: root.doc.srcQobuz === true
onChosen: QbzMyQbz.detailToggleSourceFilter("qobuz")
}
MenuOption {
label: root.trs("Plex")
selected: root.doc.srcPlex === true
onChosen: QbzMyQbz.detailToggleSourceFilter("plex")
}
MenuOption {
label: root.trs("Local")
selected: root.doc.srcLocal === true
onChosen: QbzMyQbz.detailToggleSourceFilter("local")
}
}
}
}
@@ -0,0 +1,423 @@
// MyQBZ index grid — ONE view for BOTH routes: `"mixtapes"`
// (myqbz/MixtapesView.slint, 222 lines) and `"collections"`
// (myqbz/CollectionsView.slint, 237 lines). The two .slint files are identical
// apart from the title, the model, the create kind and ONE extra kind-filter
// QbzSelect, so they are one file with a `kind` property — duplicating them
// would be the AlbumCard/ArtistCard mistake the track rules name.
// AppShell mounts it for both route ids and sets `kind`.
//
// Geometry, all off the .slint: page padding 16 top / 32 right / 100 bottom /
// 32 left (:57-60), a 40px header with 16px spacing (:65-66), a 16px spacer
// (:88), a 30px toolbar flush RIGHT with 8px spacing (:126-130), another 16px
// spacer (:169), 208x296 cards on a 20px grid gap, 64px list rows with 4px
// spacing, and the 80/16/80 empty block (:91-118).
//
// Toolbar order, left to right: search -> sort -> [kind filter, Collections
// only] -> view toggle. The view toggle's segments are LIST FIRST, grid second
// (MyQbzShared.slint:57-70) — not the grid-first order ViewModeToggle uses.
//
// NO context menu on a card or a row, no drag, no hover animation, no
// per-view back button (the shell owns the global < >). Slint declares none of
// them; do not add them.
//
// ── KNOWN DIVERGENCES, recorded so the next reader does not re-discover them ─
//
// 1. `QbzLineEdit` is NOT 1:1 with `primitives/ExpandableSearch.slint`, in
// five measurable ways that are all PRE-EXISTING and all app-wide (the
// Local Library toolbars, the tree rail, the folder detail and the queue
// ride the same control): leading glyph 13 vs 14 at `sm`
// (QbzLineEdit.qml:51 vs ExpandableSearch.slint:131), input + placeholder
// 12px vs Typography.body 15 (:148,:179 vs :114,:119), row spacing 7 vs 8
// (:126 vs :95), focus border `accent` vs `focus-ring` (:115 vs :88), and
// the X's hover fill `surfaceHover` vs `surface-elevated` (:196 vs :143).
// Fixing them is a separate app-wide commit with its own visual review; a
// MyQBZ commit must not change the Local Library search boxes.
// 2. Both selects are `sm: true` with the .slint's RAW `menu-width` (180 /
// 200), which `QbzSelect` turns into a 140 / 160 control — 30px tall, r6,
// no outline, 12px label, 14px chevron (QbzSelect.slint:88,102-105,126,
// 147-148,299). The open PANEL used to be 40px wider than the reference's
// (`Math.max(popupWidth, menuWidth)` against QbzSelect.slint:89-91's
// `max(popup-width, eff-width)`); that is FIXED in the shared control now —
// `Math.max(popupWidth, width)`. It moves only the three `sm` selects in
// the tree, all of them MyQBZ's (grid sort 180->140, kind filter 200->160,
// detail sort 170->130); the Local Library toolbar's select is not `sm` and
// the three `popupWidth` callers are not either, so nothing else changes.
// 3. Slint scrolls the WHOLE page (header and toolbar included) in one
// Flickable; here the header and toolbar are static and only the body
// scrolls, because the body must be a windowed GridView/ListView and
// nesting one inside a page Flickable is not an option. Same shape as
// views/LibraryView.qml and views/LocalLibraryView.qml.
// 4. Slint's scroll-position restore (NavState.restore-scope / scroll-restore
// / report-scroll, :38-50) has NO counterpart anywhere in this port. Omitted
// rather than reinvented as a MyQBZ-only mechanism.
// 5. Slint reads `MyQbzState.loading` in neither grid; the cardGrid skeleton
// below is the same deliberate improvement LibraryView.qml:1163-1225
// documents. The empty state stays gated on `!loading`.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../../cards"
import "../../controls"
import "../../theme"
Rectangle {
id: root
/// "mixtape" | "collection" — set by the AppShell route arm.
property string kind: "mixtape"
readonly property bool isMix: root.kind === "mixtape"
/// The bridge's grid discriminator (QbzMyQbz.gridSearch / gridSetSort /
/// gridSetView take "mixtapes" | "collections").
readonly property string gridId: root.isMix ? "mixtapes" : "collections"
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
// A LITERAL 12, as in all 14 painting view roots: QML clips are
// RECTANGULAR, so the view must round its own fill or the shell bezel
// reads square (AppShell.qml:188-258).
radius: 12
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// ONE guarded parse per document. A raw JSON.parse inside a binding throws
// on the pre-publish frame and takes the whole view down
// (PlaylistView.qml:55-61).
readonly property var doc: parseDoc()
function parseDoc() {
try {
return JSON.parse(root.isMix ? QbzMyQbz.mixtapesJson
: QbzMyQbz.collectionsJson)
} catch (e) {
return ({})
}
}
readonly property var items: root.doc.cards || []
readonly property bool loading: root.doc.loading === true
readonly property string search: root.doc.search || ""
readonly property string sortField: root.doc.sort || "position"
readonly property string viewMode: root.doc.view || "grid"
readonly property string kindFilter: root.doc.kindFilter || "all"
// Slint's own predicate (:81, plus Collections' kind-filter arm at
// CollectionsView.slint:116): the toolbar and the header "+ New" appear
// together, and when there is genuinely nothing the empty-state CTA is the
// SINGLE call to action — never both.
readonly property bool populated: root.items.length > 0
|| root.search !== ""
|| (!root.isMix && root.kindFilter !== "all")
readonly property int sortIndex: root.sortField === "name" ? 1
: root.sortField === "items" ? 2
: root.sortField === "updated" ? 3 : 0
readonly property int kindIndex: root.kindFilter === "collection" ? 1
: root.kindFilter === "artist_collection" ? 2 : 0
// Grid pitch: the 208px card + the 20px gap on both axes.
readonly property int cardW: 208
readonly property int cardH: 184 + 12 + 12 + 18 + 2 * 20 + 18 + 12 // 296
readonly property int gridGap: 20
// Content rows (the static header block above the scrolling body).
readonly property int headerTop: 16
readonly property int toolbarTop: 16 + 40 + 16 // 72
readonly property int bodyTop: root.populated ? root.toolbarTop + 30 + 16 : root.toolbarTop
// --- skeleton pulse: ONE Timer for the whole view, frozen when the view
// is not visible or the window is minimized/hidden — NEVER on lost focus
// (a tiling desktop keeps windows visible and unfocused). Copied from
// PlaylistView.qml:150-179.
property bool skelPhase: false
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
readonly property bool gridPending: root.loading && root.items.length === 0
Timer {
interval: 900
repeat: true
running: root.gridPending && root.visible && root.windowShowing
onTriggered: root.skelPhase = !root.skelPhase
}
// ============================ HEADER =============================
Item {
id: header
x: 32
y: root.headerTop
width: Math.max(0, root.width - 64)
height: 40
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, parent.width - 28 - 16)
text: root.isMix ? root.t("Mixtapes") : root.t("Collections")
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
NewActionBtn {
visible: root.populated
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMyQbz.createOpen(root.kind)
}
}
// ============================ TOOLBAR ============================
// Flush right: the Slint layout floats it with a leading stretch spacer.
Row {
id: toolbar
visible: root.populated
anchors.right: parent.right
anchors.rightMargin: 32
y: root.toolbarTop
height: 30
spacing: 8
layoutDirection: Qt.LeftToRight
QbzLineEdit {
anchors.verticalCenter: parent.verticalCenter
searchMode: true
expandable: true
sm: true
elevated: false
openWidth: 196
placeholder: root.t("Search")
text: root.search
// No debounce in the Slint (:137) — do not add one.
onEdited: function (v) { QbzMyQbz.gridSearch(root.gridId, v) }
}
QbzSelect {
anchors.verticalCenter: parent.verticalCenter
// `sm` = 30px tall, r6, no outline, 12px label, 14px chevron, and
// 40px narrower — so `menuWidth` is the .slint's RAW menu-width and
// the control lands at 180 - 40 = 140 (QbzSelect.slint:88,102-105).
sm: true
menuWidth: 180
options: [root.t("Position"), root.t("Name"), root.t("Items"),
root.t("Recently updated")]
currentIndex: root.sortIndex
onSelected: function (i) {
QbzMyQbz.gridSetSort(root.gridId,
i === 1 ? "name" : i === 2 ? "items" : i === 3 ? "updated" : "position")
}
}
// Kind filter — Collections only (CollectionsView.slint:159-174).
QbzSelect {
visible: !root.isMix
anchors.verticalCenter: parent.verticalCenter
sm: true
// 200 - 40 = 160 effective (CollectionsView.slint:160).
menuWidth: 200
options: [root.t("All kinds"), root.t("Collections"),
root.t("Artist Collections")]
currentIndex: root.kindIndex
onSelected: function (i) {
QbzMyQbz.gridSetKindFilter(
i === 1 ? "collection" : i === 2 ? "artist_collection" : "all")
}
}
QbzSegToggle {
anchors.verticalCenter: parent.verticalCenter
// LIST first, grid second (MyQbzShared.slint:57-70).
segments: [{ "id": "list", "icon": "list" },
{ "id": "grid", "icon": "layout-grid" }]
mode: root.viewMode
// The ViewToggle metric set, NOT the control's Local defaults: a
// 60x30 r6 surface-elevated well holding two `ToggleButton { sm }`
// EDGE TO EDGE — 30x30 each, no gap, 15px glyph, active fill
// surface-hover (MyQbzShared.slint:47-48 "active = surface-hover,
// NEVER accent"), hover surface-elevated and tint text-primary /
// text-muted (ToggleButton.slint:19-20,24-28,32-33,38). `segRadius`
// is 6, not the reference's 0, because a QML clip is rectangular —
// see the note in QbzSegToggle.qml.
segWidth: 30
segHeight: 30
segRadius: 6
segSpacing: 0
glyphSize: 15
activeFill: theme.surfaceHover
hoverFill: theme.surfaceElevated
activeTint: "textPrimary"
idleTint: "muted"
onSetMode: function (v) { QbzMyQbz.gridSetView(root.gridId, v) }
}
}
// ========================== EMPTY STATE ==========================
// 80 top / 16 spacing / 80 bottom, every child horizontally centred
// (:91-118). Gated on `!loading` so the skeleton owns the first frames.
Column {
visible: !root.populated && !root.loading
y: root.toolbarTop + 80
anchors.horizontalCenter: parent.horizontalCenter
spacing: 16
CollectionMosaic {
anchors.horizontalCenter: parent.horizontalCenter
size: 160
// The EFFECTIVE kind override: it picks the glyph (and, for a
// collection, would pick the 3x3 rule — moot at coverCount 0).
emptyKind: root.isMix ? "mixtape" : "collection"
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: root.isMix ? root.t("No mixtapes yet") : root.t("No collections yet")
color: theme.textSecondary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
}
NewActionBtn {
anchors.horizontalCenter: parent.horizontalCenter
onClicked: QbzMyQbz.createOpen(root.kind)
}
}
// ============================= BODY ==============================
Item {
id: body
x: 32
y: root.bodyTop
width: Math.max(0, root.width - 64)
height: Math.max(0, root.height - root.bodyTop)
// Load-bearing: the GridView below is 20px WIDER than this host (see
// the column-count note on it) and must be cut off here.
clip: true
// Search / filter produced nothing: the header "+ New" and the whole
// toolbar STAY mounted and the body becomes an 80px band (:172-183).
Text {
visible: root.populated && root.items.length === 0 && !root.loading
width: parent.width
height: 80
text: root.t("No results found")
color: theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
// First load, nothing to show yet.
QbzSkeleton {
anchors.fill: parent
visible: root.gridPending
variant: "cardGrid"
phase: root.skelPhase
cellW: root.cardW + root.gridGap
cellH: root.cardH + root.gridGap
cardW: root.cardW
cardH: root.cardH
}
// ------------------------------ GRID --------------------------
GridView {
id: grid
visible: root.viewMode === "grid" && root.items.length > 0
// COLUMN-COUNT PARITY TRAP. Slint computes
// floor((W + gap) / (cardW + gap))
// (MixtapesView.slint:187-188) — the TRAILING gap is not reserved.
// GridView computes floor(W / cellWidth), which is one column
// fewer at every exact boundary. So the view is `gridGap` wider
// than its host and the host clips: the extra 20px is the gap that
// is never painted.
width: parent.width + root.gridGap
height: parent.height
cellWidth: root.cardW + root.gridGap
cellHeight: root.cardH + root.gridGap
cacheBuffer: 2 * (root.cardH + root.gridGap)
reuseItems: true
// Clears the player bar, the .slint's 100px page bottom padding.
bottomMargin: 100
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.items
delegate: MyQbzCard {
required property var modelData
width: root.cardW
item: modelData
onClicked: QbzMyQbz.openCard(modelData.id)
}
}
// ------------------------------ LIST --------------------------
ListView {
id: list
visible: root.viewMode === "list" && root.items.length > 0
anchors.fill: parent
// Ten rows of pitch 68 (64 + the 4px spacing), spec 01 §5.5.
cacheBuffer: 68 * 10
reuseItems: true
spacing: 4
bottomMargin: 100
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.items
delegate: MyQbzCard {
required property var modelData
width: list.width
compact: true
item: modelData
onClicked: QbzMyQbz.openCard(modelData.id)
}
}
}
// Thin auto-hiding scrollbars (the ListScrollbar replica), one per body
// view — LibraryView.qml:1443-1459's shape. Children of the ROOT, not of
// `body`: the .slint pins the bar at `parent.width - width - 4` of the VIEW
// and spans its FULL height (MixtapesView.slint:213-221), i.e. outside the
// 32px content padding, and the 32px right pad exists precisely so the grid
// never rides under it.
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: grid
visible: grid.visible && grid.contentHeight > grid.height
}
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: list
visible: list.visible && list.contentHeight > list.height
}
// The "+ New" header/empty-state action (MyQbzShared.slint:24-42): a 28x28
// surface-elevated CIRCLE with a plus glyph — never an accent fill, and
// never the deleted blue PrimaryCta. Inline because both call sites are in
// this file.
component NewActionBtn: Rectangle {
id: newBtn
signal clicked()
width: 28
height: 28
radius: 14
color: newArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
QbzIcon {
anchors.centerIn: parent
name: "plus"
width: 15
height: 15
tintName: "textPrimary"
}
MouseArea {
id: newArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: newBtn.clicked()
}
}
}
+92 -24
View File
@@ -4,18 +4,23 @@
//! redirect PATH; a mismatched/absent path nonce is dropped) that the
//! desktop auth.rs lacks.
//!
//! POC-NOTE (skipped vs the Slint glue, for the effort-measurement report):
//! the per-user store fan-out after a successful login/restore is NOT
//! ported. Omitted: `crate::offline::activate`, `offline_cache::
//! load_cached_ids`, `fav_cache`, `reco_dismiss`, `reco` (+train_async),
//! `external_reco`, `qbz_reco::ArtistVectorStore`, `discover_prefs`,
//! `artist_blacklist`, `pinned`, `local_favorites`, `search_service`,
//! `session_persist`, `lyrics`. Reason: the POC shell has no views that
//! read those stores; each is a mechanical `init_for_user(&dir)` call to
//! re-add when its view lands. Also omitted: the subscription purge
//! consumer (`spawn_subscription_purge_check`) — it needs the offline
//! cache, which this phase does not bring up; TODO when the offline cache
//! is wired.
//! POC-NOTE (the per-user store fan-out, for the effort-measurement report).
//! The three activation paths share ONE helper, `bind_per_user_stores`, plus
//! the phase-ordered calls above it; adding a store means one line there and
//! its matching `teardown()` in `logout`.
//!
//! Bound: offline mode, Plex settings, local favorites, the Qobuz
//! favourite-id cache, pinned items, intelligent search, the playlist owner
//! id, MyQBZ branding + view prefs, the MyQBZ grids + mixtape schema, the
//! artist/album blacklist, and the Recommendations dismissal set.
//!
//! Still omitted (no view reads them yet; each is a mechanical
//! `init_for_user(&dir)` when its view lands): `crate::offline::activate`,
//! `offline_cache::load_cached_ids`, `reco` (+train_async), `external_reco`,
//! `qbz_reco::ArtistVectorStore`, `discover_prefs`, `session_persist`,
//! `lyrics`. Also omitted: the subscription purge consumer
//! (`spawn_subscription_purge_check`) — it needs the offline cache, which
//! this phase does not bring up; TODO when the offline cache is wired.
//!
//! KEPT (the offline guardrails this phase is about):
//! `ensure_api_initialized` (scoped gate lift), `login_with_oauth_code`,
@@ -164,6 +169,7 @@ where
crate::search_qt::init(&dir, crate::search_qt::intelligent_search_pref());
// Phase 17: playlist is_owner math.
crate::playlist_qt::set_user_id(user_id);
bind_per_user_stores(&dir, user_id);
}
crate::offline_fwd::subscription_mark_valid();
crate::offline_fwd::engine().set_offline_session(false);
@@ -181,6 +187,31 @@ where
})
}
/// Per-user stores bound on EVERY session activation — fresh login, silent
/// restore and the offline entry alike.
///
/// One helper rather than three copies, because the three activation paths
/// drifting apart is not a hypothetical: `qbz/src/artist_blacklist.rs:41-46`
/// records that Tauri bound the blacklist on login and restore but NOT
/// offline, so blacklisted artists leaked into every offline surface. Anything
/// added here lands on all three by construction.
///
/// `myqbz_qt::init_for_user` also runs the mixtape migrations (best-effort,
/// logs on failure, never blocks shell entry) — one entry point, so the caller
/// cannot bind the uid and forget the schema.
fn bind_per_user_stores(dir: &std::path::Path, user_id: u64) {
// MyQBZ: branding (label + icon, read by the sidebar/nav flyout on frame
// 1) and the grids + mixtape schema.
crate::myqbz_prefs_qt::init_for_user(dir);
crate::myqbz_qt::init_for_user(dir, user_id);
// Artist/album blacklist. Until this binds, every read fail-opens
// (`is_enabled()` → true, empty manager) and every mutation refuses with
// "No active session" — the feature looks broken but never panics.
crate::artist_blacklist::init_for_user(dir);
// "Not interested" dismissals on Recommendations cards.
crate::reco_dismiss_qt::init_for_user(dir);
}
/// Make sure the Qobuz client holds bundle tokens before a sign-in call.
/// Scoped gate lift around the gated cold bundle init — verbatim port of
/// the desktop pattern (crates/qbz/src/auth.rs `ensure_api_initialized`).
@@ -259,18 +290,20 @@ where
// skipped (see module docs).
if let Some(dir) = crate::offline_fwd::user_data_dir(user_id) {
crate::offline_fwd::init_for_user(&dir);
// Plex settings live per-user (plex_settings.db); bind the
// store to this session so the first Local Library read does
// not have to.
crate::local_plex::init_for_user(&dir);
// Plex settings live per-user (plex_settings.db); bind the store to
// this session so the first Local Library read does not have to.
crate::local_plex::init_for_user(&dir);
crate::library_qt::init_local_favorites(&dir);
// Qobuz favourite-id cache — see the fresh-login path.
crate::fav_cache_qt::init_for_user(&dir);
crate::sidebar_qt::init_pinned(&dir);
// Phase 15: intelligent-search service (cortinilla cache+ranking).
crate::search_qt::init(&dir, crate::search_qt::intelligent_search_pref());
// Phase 17: playlist is_owner math.
crate::playlist_qt::set_user_id(user_id);
// Phase 15: intelligent-search service (cortinilla
// cache+ranking).
crate::search_qt::init(&dir, crate::search_qt::intelligent_search_pref());
// Phase 17: playlist is_owner math.
crate::playlist_qt::set_user_id(user_id);
bind_per_user_stores(&dir, user_id);
}
crate::offline_fwd::subscription_mark_valid();
crate::offline_fwd::engine().set_offline_session(false);
@@ -346,6 +379,9 @@ where
crate::search_qt::init(&dir, crate::search_qt::intelligent_search_pref());
// Phase 17: playlist is_owner math.
crate::playlist_qt::set_user_id(user_id);
// NOT optional on the offline path — see the helper's doc comment for
// the leak this closes.
bind_per_user_stores(&dir, user_id);
}
crate::offline_fwd::engine().set_offline_session(true);
Ok(user_id)
@@ -355,11 +391,28 @@ where
/// the Qobuz client session, and tear down the offline-mode per-user state
/// (which reopens the Qobuz gate so a logged-out user can sign back in).
///
/// POC-NOTE: the Slint logout also tears down the per-user stores this phase
/// never opens (offline cache, reco, artist vectors, discover prefs,
/// blacklist, pinned, local favorites, search, lyrics). The favourite-id
/// cache IS open now, and it holds the previous user's hearts — dropping it
/// is not optional, or the next account inherits them.
/// POC-NOTE: the Slint logout also tears down per-user stores this phase never
/// opens — offline cache (`offline::deactivate`), reco, artist vectors
/// (`clear_artist_vectors`), discover prefs and lyrics. It does NOT still cover
/// pinned / local favorites / search: this phase DOES open those three (see the
/// activation paths above), which is why they moved to the asymmetry list below
/// rather than staying here.
///
/// The stores that ARE open must be dropped here, and that is not cosmetic:
/// each holds the PREVIOUS user's data in a process-global, so skipping one
/// means the next account inherits it. The favourite-id cache holds their
/// hearts; MyQBZ holds their mixtape grids, the open collection's items, both
/// item caches and the selection set; the add-picker holds the pending batch
/// plus their collection list; the blacklist holds their blocked artists and
/// albums; reco-dismiss holds their "not interested" set.
///
/// STILL ASYMMETRIC (an `init_for_user` above with no teardown here, because
/// the module exposes none — each needs its own file changed, so it is not
/// done from this side): `local_plex`, `library_qt::init_local_favorites`,
/// `sidebar_qt::init_pinned`, `search_qt::init`, `playlist_qt::set_user_id`.
/// The reference DOES tear the last three down (`pinned::teardown`,
/// `local_favorites::teardown`, `search_service::teardown` —
/// `qbz/src/auth.rs:349-352`).
pub async fn logout<A>(runtime: &Arc<AppRuntime<A>>) -> Result<(), String>
where
A: FrontendAdapter + Send + Sync + 'static,
@@ -367,6 +420,21 @@ where
let _ = qbz_credentials::clear_oauth_token();
let _ = runtime.core().logout().await;
crate::fav_cache_qt::teardown();
// MyQBZ: grids + branding/view-prefs binding, then the detail page's
// caches and the two modal documents.
crate::myqbz_qt::teardown();
crate::myqbz_prefs_qt::teardown();
crate::myqbz_detail_qt::teardown();
crate::myqbz_edit_qt::teardown();
crate::myqbz_mix_qt::teardown();
// The app-wide "Add to Mixtape/Collection" picker: its row cache holds the
// previous account's collection ids, which a pick would write into.
crate::myqbz_add_qt::teardown();
// The Artist-Collection builder holds a whole discography plus the artist
// id it was opened for.
crate::myqbz_builder_qt::teardown();
crate::artist_blacklist::teardown();
crate::reco_dismiss_qt::teardown();
crate::offline_fwd::teardown();
runtime.deactivate().await?;
log::info!("[qbz-qt] logged out");
+201
View File
@@ -0,0 +1,201 @@
//! QbzDisco — Discography Builder domain bridge (spec 02 §4.3).
//!
//! The module is `qbz_disco_bridge`; bridge modules are never named after a
//! workspace crate (library_bridge.rs:1-6 precedent). The QML type name comes
//! from the QObject (`QbzDisco`), not from the module.
//!
//! A SEPARATE singleton from `QbzMyQbz` on purpose: the builder is reached from
//! the ARTIST page (ArtistView's ⋯ overflow → "Create Artist Collection"), owns
//! an artist-scoped session of its own, and never coexists with the MyQBZ views.
//! It is also the ONLY creator of `kind == artist_collection`, which both grids
//! and the detail render.
//!
//! Props: the one builder document. No `#[qsignal]` — outcomes are toasts
//! (toast_qt.rs, and note that "Artist Collection created" / "Failed to create
//! collection" are RAW English literals in the reference, absent from all eight
//! catalogs, reproduced verbatim) and state changes are property republishes.
//!
//! Route id: the builder view mounts on `QbzShell.currentView == "discobuilder"`
//! (orchestrator's canonical route table; spec 02's `"disco"` is superseded by
//! spec 01 §1.1). The `nav_qt::record` call lives in the controller's `open`, not
//! here.
//!
//! Deliberately NOT bridge members: `open-type-menu-key` (pure popover
//! bookkeeping — the Qt popup owns its own open state) and
//! `name-trimmed-empty` / `name-changed` (Slint cannot `trim()`; QML can, and
//! `create(name)` takes the name directly).
use std::pin::Pin;
use std::sync::OnceLock;
use cxx_qt::CxxQtThread;
use cxx_qt::Threading as _;
use cxx_qt_lib::QString;
#[cxx_qt::bridge]
pub mod qbz_disco_bridge {
extern "C++" {
include!("cxx-qt-lib/qstring.h");
type QString = cxx_qt_lib::QString;
}
#[auto_cxx_name]
extern "RustQt" {
#[qobject]
#[qml_element]
#[qml_singleton]
// ONE JSON document (myqbz_builder_qt.rs DiscoDoc: artist header,
// loading/error, order, tri-state header flags, footer counts and the
// grouped candidates with their alternates).
#[qproperty(QString, disco_json)]
type QbzDisco = super::QbzDiscoRust;
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
/// singleton; only QbzSession.boot also fires crate::on_boot).
#[qinvokable]
fn boot(self: Pin<&mut QbzDisco>);
/// Entry point from ArtistView's ⋯ overflow: nav-record the route, reset
/// the session, fetch the three sources (Qobuz first — it resolves the
/// artist name — then local + Plex), group and publish.
#[qinvokable]
fn open(self: Pin<&mut QbzDisco>, artist_id: QString);
/// Cancel button — plain history back.
#[qinvokable]
fn back(self: Pin<&mut QbzDisco>);
/// `order_by` ∈ release_date | title | manual.
#[qinvokable]
fn set_order(self: Pin<&mut QbzDisco>, order_by: QString);
/// Per-candidate checkbox (`cand_key` is "{source}|{sourceItemId}").
#[qinvokable]
fn toggle_checked(self: Pin<&mut QbzDisco>, group_key: QString, cand_key: QString);
/// Header tri-state: touches non-compilation PRIMARIES only —
/// alternates and compilations keep whatever the user set.
#[qinvokable]
fn toggle_all(self: Pin<&mut QbzDisco>);
/// Title click → the album page, when the id is non-blank.
#[qinvokable]
fn open_album(self: Pin<&mut QbzDisco>, source: QString, source_item_id: QString);
/// Release-type override: writes the process-global sidecar and
/// republishes. `choice` ∈ album | ep | single | live | compilation.
#[qinvokable]
fn set_type_override(
self: Pin<&mut QbzDisco>,
source: QString,
source_item_id: QString,
choice: QString,
);
/// Clears one override (back to the classifier's verdict).
#[qinvokable]
fn reset_type_override(
self: Pin<&mut QbzDisco>,
source: QString,
source_item_id: QString,
);
/// Create the artist_collection: trimmed-empty name ⇒ the raw literal
/// "Artist Collection" (untranslated, 1:1 with myqbz_builder.rs:858),
/// then navigate to the new collection's detail.
#[qinvokable]
fn create(self: Pin<&mut QbzDisco>, name: QString);
}
impl cxx_qt::Threading for QbzDisco {}
}
use qbz_disco_bridge::QbzDisco;
/// Rust side of the Discography Builder bridge (plain storage, phase-1 pattern).
pub struct QbzDiscoRust {
disco_json: QString,
}
impl Default for QbzDiscoRust {
fn default() -> Self {
Self {
// Parseable default so QML's JSON.parse never throws on frame 1
// (home_bridge.rs:250-257).
disco_json: QString::from("{}"),
}
}
}
// ---------------------------------------------------------------------------
// The UI hop (bridges/mod.rs pattern)
// ---------------------------------------------------------------------------
static QT_THREAD: OnceLock<CxxQtThread<QbzDisco>> = OnceLock::new();
/// Queue a builder-bridge mutation onto the Qt event loop (no-op before boot
/// registers the thread).
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzDisco>) + Send + 'static) {
if let Some(thread) = QT_THREAD.get() {
let _ = thread.queue(f);
}
}
impl qbz_disco_bridge::QbzDisco {
pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() {
log::warn!("[qbz-qt] disco Qt thread already registered");
}
// The builder has no session until an artist page opens it, and the
// empty document is already seeded in Default.
}
pub fn open(self: Pin<&mut Self>, artist_id: QString) {
crate::myqbz_builder_qt::open(artist_id.to_string());
}
pub fn back(self: Pin<&mut Self>) {
crate::myqbz_builder_qt::back();
}
pub fn set_order(self: Pin<&mut Self>, order_by: QString) {
crate::myqbz_builder_qt::set_order(&order_by.to_string());
}
pub fn toggle_checked(self: Pin<&mut Self>, group_key: QString, cand_key: QString) {
crate::myqbz_builder_qt::toggle_checked(&group_key.to_string(), &cand_key.to_string());
}
pub fn toggle_all(self: Pin<&mut Self>) {
crate::myqbz_builder_qt::toggle_all();
}
pub fn open_album(self: Pin<&mut Self>, source: QString, source_item_id: QString) {
crate::myqbz_builder_qt::open_album(&source.to_string(), &source_item_id.to_string());
}
pub fn set_type_override(
self: Pin<&mut Self>,
source: QString,
source_item_id: QString,
choice: QString,
) {
crate::myqbz_builder_qt::set_type_override(
&source.to_string(),
&source_item_id.to_string(),
&choice.to_string(),
);
}
pub fn reset_type_override(self: Pin<&mut Self>, source: QString, source_item_id: QString) {
crate::myqbz_builder_qt::reset_type_override(
&source.to_string(),
&source_item_id.to_string(),
);
}
pub fn create(self: Pin<&mut Self>, name: QString) {
crate::myqbz_builder_qt::create(name.to_string());
}
}
+37 -9
View File
@@ -48,8 +48,21 @@
//!
//! THEME-FOLLOWING (baked here): `textPrimary`, `secondary` / `textSecondary`,
//! `muted` / `textMuted`, `disabled` / `textDisabled`, `accent`, `accentText`,
//! `warning`, `favorite` — each named after its `ThemeColors` key
//! (theme_qt.rs `ThemeTokens`).
//! `warning`, `favorite`, `success`, `danger` — each named after its
//! `ThemeColors` key (theme_qt.rs `ThemeTokens`).
//!
//! `success` / `danger` arrived with the shared toast (primitives/Toast.slint
//! tints its per-kind glyph with Theme.success / Theme.danger). They are the
//! only two theme-following tints with NO qrc bake directory of their own, so
//! the FLOOR does not carry them: `QbzIcon.qml`'s `qrcTint` switch falls to its
//! `default:` arm, returns the tint name unchanged, and asks for
//! `assets/icons/success/<name>.svg` — a directory that does not exist, which
//! means the glyph is simply ABSENT with nothing logged. Reachable exactly when
//! the runtime bake is (no writable cache dir, a pre-publish theme document, a
//! pruned directory). `QbzIcon.qml` needs two arms in that switch —
//! `case "danger": return "favorite"` (#ef4444 vs #e0564f, the port-wide
//! substitution) and `success` onto a dir whose polarity is legible (there is
//! no green bake) — before the floor can be trusted for these two.
//!
//! FIXED (kept on their qrc bake): `black`, `amber` — and `primary`.
//!
@@ -77,7 +90,7 @@
//!
//! # Cost
//!
//! One pass of 96 small string substitutions + writes (~380 KB) the first
//! One pass of 99 small string substitutions + writes (~390 KB) the first
//! time a colour is seen, on the Qt thread inside a binding evaluation
//! (measured in single-digit ms on any SSD); nothing at all afterwards. The
//! cache is pruned to `MAX_TINT_DIRS` directories, oldest first.
@@ -131,10 +144,10 @@ pub struct QbzIconTintRust {
/// Every icon the app can ask for, as the `primary/` bake's source text.
///
/// This IS the shipped `qml/assets/icons/primary/` directory (96 files),
/// This IS the shipped `qml/assets/icons/primary/` directory (99 files),
/// compiled in. Two consequences worth knowing:
/// - every runtime tint is COMPLETE, so the partial-directory trap the qrc
/// bakes have (`favorite/` = 52 files, `amber/` = 1) does not exist here;
/// bakes have (`favorite/` = 56 files, `amber/` = 4) does not exist here;
/// - a new icon dropped into `primary/` needs a line ADDED here, otherwise
/// that one glyph silently drops back to its qrc bake (visible, just not
/// theme-correct). `QbzIcon.qml` handles the miss; it does not go blank.
@@ -154,6 +167,8 @@ const MASTERS: &[(&str, &str)] = &[
("chevron-left", include_str!("../qml/assets/icons/primary/chevron-left.svg")),
("chevron-right", include_str!("../qml/assets/icons/primary/chevron-right.svg")),
("chevron-up", include_str!("../qml/assets/icons/primary/chevron-up.svg")),
("circle-alert", include_str!("../qml/assets/icons/primary/circle-alert.svg")),
("circle-check-big", include_str!("../qml/assets/icons/primary/circle-check-big.svg")),
("clock", include_str!("../qml/assets/icons/primary/clock.svg")),
("cloud-download", include_str!("../qml/assets/icons/primary/cloud-download.svg")),
("cloud-off", include_str!("../qml/assets/icons/primary/cloud-off.svg")),
@@ -173,6 +188,7 @@ const MASTERS: &[(&str, &str)] = &[
("heart-filled", include_str!("../qml/assets/icons/primary/heart-filled.svg")),
("heart", include_str!("../qml/assets/icons/primary/heart.svg")),
("home-gear", include_str!("../qml/assets/icons/primary/home-gear.svg")),
("image", include_str!("../qml/assets/icons/primary/image.svg")),
("import", include_str!("../qml/assets/icons/primary/import.svg")),
("infinity", include_str!("../qml/assets/icons/primary/infinity.svg")),
("info", include_str!("../qml/assets/icons/primary/info.svg")),
@@ -231,6 +247,8 @@ const MASTERS: &[(&str, &str)] = &[
("translate", include_str!("../qml/assets/icons/primary/translate.svg")),
("trash-2", include_str!("../qml/assets/icons/primary/trash-2.svg")),
("trash-list", include_str!("../qml/assets/icons/primary/trash-list.svg")),
("triangle-alert", include_str!("../qml/assets/icons/primary/triangle-alert.svg")),
("turntable", include_str!("../qml/assets/icons/primary/turntable.svg")),
("user-plus", include_str!("../qml/assets/icons/primary/user-plus.svg")),
("user", include_str!("../qml/assets/icons/primary/user.svg")),
("volume-2", include_str!("../qml/assets/icons/primary/volume-2.svg")),
@@ -238,8 +256,8 @@ const MASTERS: &[(&str, &str)] = &[
("x", include_str!("../qml/assets/icons/primary/x.svg")),
];
/// The only paint literal in a `primary/` master (verified over all 96 files:
/// 105 occurrences, no `#fff` short form). The uppercase form is accepted too
/// The only paint literal in a `primary/` master (verified over all 99 files:
/// 109 occurrences, no `#fff` short form). The uppercase form is accepted too
/// so a hand-added master cannot silently skip the substitution.
const MASTER_HEX_LOWER: &str = "#ffffff";
const MASTER_HEX_UPPER: &str = "#FFFFFF";
@@ -247,7 +265,7 @@ const MASTER_HEX_UPPER: &str = "#FFFFFF";
/// Written last into a tint directory; its presence means "complete".
const STAMP: &str = ".complete";
/// How many tinted sets to keep on disk (~380 KB each). 36 themes x 7 tokens
/// How many tinted sets to keep on disk (~390 KB each). 36 themes x 10 tokens
/// is the theoretical ceiling, but the palettes share most of their text ramp,
/// so this covers ordinary theme-hopping without unbounded growth.
const MAX_TINT_DIRS: usize = 32;
@@ -266,6 +284,8 @@ const TOKENS: &[&str] = &[
"accentText",
"warning",
"favorite",
"success",
"danger",
];
/// The `ThemeTokens` (theme_qt.rs) key a tint follows, or `None` for the tints
@@ -283,6 +303,14 @@ fn token_for(tint: &str) -> Option<&'static str> {
"accentText" => "accentText",
"warning" => "warning",
"favorite" => "favorite",
// The toast's per-kind glyph tints (primitives/Toast.slint:20-38 hands
// Theme.success / Theme.danger straight to the icon). QbzIcon exposes
// `tintName` only — never a raw colour — so without these two entries
// there is no way for a QML call site to reach either token, and both
// keys already exist in every theme document (theme_qt.rs:159,173;
// QbzTheme.qml:28,32,70,78).
"success" => "success",
"danger" => "danger",
// Fixed on purpose (see the module header):
// "primary" / "white": a light glyph on a host that is dark under
// every theme (scrims, gradients, accent fills)
@@ -364,7 +392,7 @@ fn tints_from_json(json: &str) -> HashMap<&'static str, String> {
// Baking
// ---------------------------------------------------------------------------
/// Materialize `<root>/<hex>/` with all 96 glyphs. Returns false on any I/O
/// Materialize `<root>/<hex>/` with all 99 glyphs. Returns false on any I/O
/// failure (caller falls back to qrc).
///
/// The set is built in a temp directory and RENAMED into place, so a crash or
+11 -1
View File
@@ -37,7 +37,17 @@ fn db_path() -> Option<PathBuf> {
/// account has no library.db until the first local flag is set), reads must
/// NOT — an empty result is the right answer and creating the file as a side
/// effect of a read is a surprise.
fn with_db<F, R>(create: bool, f: F) -> Option<R>
///
/// `pub(crate)` because this is the port's only CREATE-CAPABLE accessor, and
/// MyQBZ needs it twice on a fresh account: `run_mixtape_migrations` at
/// session activation and the first `create_collection`. The other accessor,
/// `local_state::with_db` (already `pub`), is the right one for every
/// `local_*` read, but it returns `None` when library.db is absent
/// (`local_state.rs:44-46`) — through it a brand-new account could never run
/// the migrations, so it could never create a mixtape. Slint has no such
/// split: its `library_db::open` always creates
/// (`qbz/src/library_db.rs:54-64`).
pub(crate) fn with_db<F, R>(create: bool, f: F) -> Option<R>
where
F: FnOnce(&LibraryDatabase) -> Result<R, LibraryError>,
{
+39 -12
View File
@@ -18,10 +18,11 @@
//! that. The split is cached in `LocalState` so the picker switches with NO DB
//! round-trip (the Slint's `ALBUM_VERSIONS` static, 1:1).
//!
//! NOT WIRED (deliberate, reported): `album_edit_tags`, `album_add_to_playlist`
//! and `album_add_to_mixtape` are LOGGED SEAMS. The Qt port has no tag-editor
//! modal, no playlist picker and no Mixtape/Collection store, and a menu item
//! must never write tags to disk with no UI in front of it.
//! NOT WIRED (deliberate, reported): `album_edit_tags` and
//! `album_add_to_playlist` are LOGGED SEAMS. The Qt port has no tag-editor
//! modal and no playlist picker, and a menu item must never write tags to disk
//! with no UI in front of it. `album_add_to_mixtape` is LIVE since the MyQBZ
//! domain landed — it opens the Mixtape/Collection picker.
use std::collections::HashMap;
use std::sync::Arc;
@@ -382,15 +383,41 @@ pub fn add_to_playlist(id: String) {
);
}
/// Album header 📼: the Slint builds a MyQBZ `AddItem` and opens the
/// Mixtape/Collection picker. Neither the store nor the modal exists in the Qt
/// port, so this is a logged seam.
/// Album header 📼: ONE `album` payload (source "local", no artwork_url, no
/// year) plus the SELECTED version's track count, then the Mixtape/Collection
/// picker — 1:1 with `LocalAlbumActions::on_add_to_mixtape`
/// (`qbz/src/main.rs:18714-18737`).
///
/// Title and artist come from `album_header`, the same function that produced
/// the header the user is looking at, so a version switch is reflected (the
/// Slint reads `LocalAlbumState`, which `apply_album_version` recomputes for
/// exactly that reason).
///
/// Deviation, deliberate: the reference does not check the track list and lets
/// `track_count` fall to `None`; here an empty list means no version is open at
/// all, and `album_header` indexes `tracks[0]`, so it is refused with a log
/// instead of panicking.
pub fn add_to_mixtape(id: String) {
let n = current_version_tracks().len();
log::info!(
"[qbz-qt] local album add-to-mixtape: no Mixtape/Collection store in the Qt port yet \
(album '{id}', {n} tracks) — seam only"
);
if id.is_empty() {
return;
}
let tracks = current_version_tracks();
if tracks.is_empty() {
log::warn!("[qbz-qt] local album add-to-mixtape: no open version for '{id}', ignored");
return;
}
let row = album_header(&id, &tracks);
let item = crate::myqbz_add_qt::AddItem {
item_type: "album".into(),
source: "local".into(),
source_item_id: id,
title: row.title,
subtitle: (!row.artist.is_empty()).then_some(row.artist),
artwork_url: None,
year: None,
track_count: Some(tracks.len() as i32),
};
crate::myqbz_add_qt::open_items(vec![item]);
}
// ---------------------------------------------------------------------------
+14 -4
View File
@@ -187,11 +187,21 @@ async fn apply(rows: Vec<LocalTrack>, action: &str) -> bool {
enqueue_rows(rows, action).await;
true
}
// GAP (not a half-path): this port has no playlist picker modal and
// no mixtape store — the Slint routes these into
// `playlist_picker::open_multi` / `myqbz_add`, neither of which was
// The MyQBZ picker — 1:1 with the Slint's `myqbz_add` route. Returns
// FALSE (keep the selection): the picker is still open and a failed
// write is retried from the same modal, which is exactly what this
// function's doc-comment describes.
"add-to-mixtape" => {
if rows.is_empty() {
return false;
}
crate::myqbz_add_qt::open_items(crate::myqbz_add_qt::track_items_from_local(&rows));
false
}
// GAP (not a half-path): this port has no playlist picker modal — the
// Slint routes this into `playlist_picker::open_multi`, which was not
// ported. Same gap the PlayerBar / NowPlayingBarSmall menus document.
"add-to-playlist" | "add-to-mixtape" => {
"add-to-playlist" => {
log::warn!(
"[qbz-qt] local bulk {action}: no picker seam in this port ({} row(s) dropped)",
rows.len()
+91
View File
@@ -31,6 +31,16 @@ mod library_bridge;
mod album_bridge;
mod artist_bridge;
mod cast_bridge;
// MyQBZ splits across THREE singletons rather than one, because the two
// modals are global overlays with their own lifetime: QbzMyQbz carries the
// two grids + the detail page + the edit/mix modals + the branding,
// QbzMyQbzAdd carries only the app-wide "Add to Mixtape/Collection" picker
// (mounted in AppShell, reachable from any view's row menu), and QbzDisco
// carries the Artist-Collection builder.
mod myqbz_bridge;
mod myqbz_add_bridge;
mod disco_bridge;
mod blacklist_bridge;
mod artwork_qt;
mod atmosphere_qt;
mod bridge;
@@ -56,6 +66,31 @@ mod local_bridge_ops;
mod local_bulk;
mod local_ephemeral;
mod local_album_actions;
// MyQBZ domain controllers. One module per concern, all driven by the three
// bridges above: the grids + Create modal (myqbz_qt), the per-user branding
// and per-collection view prefs (myqbz_prefs_qt), the detail page
// (myqbz_detail_qt) and its playback / edit / cover / DJ-mix arms, the
// app-wide Add picker (myqbz_add_qt) and the Artist-Collection builder
// (myqbz_builder_qt + its Qobuz/local/Plex fetchers).
mod myqbz_qt;
mod myqbz_prefs_qt;
mod myqbz_detail_qt;
mod myqbz_play_qt;
mod myqbz_edit_qt;
mod myqbz_cover_qt;
mod myqbz_mix_qt;
mod myqbz_add_qt;
mod myqbz_builder_qt;
mod myqbz_builder_fetch_qt;
// Blacklist: the per-user store (artist_blacklist), the Recommendations
// dismissal store (reco_dismiss_qt) and the manager view's controller.
mod artist_blacklist;
mod reco_dismiss_qt;
mod blacklist_qt;
// Shared in-app toast publisher (the port of qbz-slint-common's toast.rs).
// A plain module, NOT a bridge — it publishes onto QbzShell.toastJson, and
// `controls/QbzToast.qml` owns the auto-hide timer.
mod toast_qt;
mod nav_qt;
mod browse_qt;
mod cast_qt;
@@ -158,6 +193,15 @@ pub(crate) fn on_boot() {
});
offline_fwd::start_ui_forwarder();
// Derivative-cache housekeeping, off the Qt thread. Once per run: the
// `.jpg` orphan sweep is idempotent (FIX 1 moved the scaled derivatives to
// `.png`, so every `.jpg` left in `images/scaled/` is dead weight from a
// pre-fix build) and the byte cap is cheap — one `read_dir`, then unlink
// the oldest until the directory is back under the ceiling.
spawn(async {
let _ = tokio::task::spawn_blocking(artwork_qt::housekeeping).await;
});
// Splash -> silent session restore -> shell | login.
let runtime = app();
spawn(async move {
@@ -807,6 +851,26 @@ pub(crate) fn open_playlist(playlist_id: String) {
});
}
// ======================= MyQBZ (crate-level forwards) =====================
//
// W6: the ONLY crate-root forward the MyQBZ domain needs. Everything else the
// three bridges call, they call straight at its controller module
// (`crate::myqbz_qt::…`, `crate::myqbz_builder_qt::…`) — a thin forward per
// invokable would be 48 dead one-liners.
//
// This one exists because the CONTROLLERS need it, not the bridges: both
// `myqbz_qt::create_submit` (Create modal) and `myqbz_builder_qt::create`
// (Artist-Collection builder) navigate to the collection they just created,
// and neither should reach into a sibling controller to do it.
/// Open a mixtape/collection detail page by id — the shared "created, now go
/// there" hop. `myqbz_detail_qt::open` records the `"mixtapedetail"` route
/// itself, so this deliberately does NOT call `navigate_to` (which would
/// record a second entry and clear `LAST_DETAIL` twice).
pub(crate) fn myqbz_open_detail(id: String) {
myqbz_detail_qt::open(id);
}
pub(crate) fn playlist_play_all() {
let runtime = app();
spawn(async move {
@@ -1107,6 +1171,21 @@ pub(crate) fn navigate_to(view: &str) {
if view == "settings" {
publish_settings();
}
// MyQBZ: BOTH grids reload on EVERY visit — deliberately no once-flag
// (contrast `library` above). A create from the Add picker, a delete from
// the detail view or a new Artist Collection from the builder all change
// the set while the grid is unmounted, so a once-per-session flag would
// paint a stale grid. The read is one local SQLite query.
if view == "mixtapes" {
myqbz_qt::load_grid(myqbz_qt::Grid::Mixtapes);
}
if view == "collections" {
myqbz_qt::load_grid(myqbz_qt::Grid::Collections);
}
// "mixtapedetail", "discobuilder" and "blacklist" need no arm: each is
// only ever reached through a call that loads its own data first and
// records the route itself (myqbz_detail_qt::open, myqbz_builder_qt::open,
// blacklist_qt::open_manager), never through the sidebar.
}
// ============================ i18n (phase 20) ==============================
@@ -1137,6 +1216,18 @@ pub(crate) fn apply_language(code: String) {
reload_home();
reload_library();
publish_settings();
// MyQBZ builds a handful of strings in RUST — the kind eyebrow
// ("MIXTAPE" / "COLLECTION" / "ARTIST"), the "{} albums" plural, every
// unresolved row's TYPE label, and the builder's footer count + default
// collection name. None of them re-translate through trRev, because they
// arrive as data inside a JSON document, so each owner republishes.
//
// Unconditional, unlike the album/artist republish below: `navigate_to`
// clears LAST_DETAIL, so routing MyQBZ through that latch would silently
// skip it. Each of these is a no-op on an empty document.
myqbz_qt::republish_all();
myqbz_detail_qt::republish();
myqbz_builder_qt::republish();
let (view, id) = LAST_DETAIL.lock().unwrap().clone();
if !id.is_empty() {
match view.as_str() {
+159
View File
@@ -0,0 +1,159 @@
//! QbzMyQbzAdd — the app-wide "Add to Mixtape/Collection" picker (spec 02 §4.2).
//!
//! The module is `qbz_myqbz_add_bridge`, never `qbz_mixtape`: bridge modules are
//! never named after a workspace crate (library_bridge.rs:1-6 precedent). The
//! QML type name comes from the QObject, so QML is unaffected.
//!
//! A SEPARATE singleton from `QbzMyQbz` on purpose: this is the only MyQBZ
//! surface other domains' QML touches (TrackRow, PlayerBar, NowPlayingBarSmall
//! and the three Local Library bulk surfaces), so keeping it apart means none of
//! them can couple to detail state, and a detail-side publish can never perturb
//! an open picker.
//!
//! Props: the one picker document. No `#[qsignal]` — outcomes are toasts
//! (toast_qt.rs) and state changes are property republishes.
//!
//! The payload is a JSON ARRAY rather than per-caller invokables: Slint builds
//! `Vec<AddItem>` in Rust because Slint models cannot be read generically
//! (main.rs:2337,2369,5453 are three hand-written builders), while in Qt every
//! caller already holds the row as a parsed JSON object Rust produced, so
//! `JSON.stringify([...])` is one call with no Rust-side lookup table. Precedent
//! for a JSON argument: `QbzShell.sidebarArtworkWindow(urlsJson)`
//! (shell_bridge.rs:263).
use std::pin::Pin;
use std::sync::OnceLock;
use cxx_qt::CxxQtThread;
use cxx_qt::Threading as _;
use cxx_qt_lib::QString;
#[cxx_qt::bridge]
pub mod qbz_myqbz_add_bridge {
extern "C++" {
include!("cxx-qt-lib/qstring.h");
type QString = cxx_qt_lib::QString;
}
#[auto_cxx_name]
extern "RustQt" {
#[qobject]
#[qml_element]
#[qml_singleton]
// ONE JSON document (myqbz_add_qt.rs AddDoc: open/loading flags, header
// strings, the create sub-panel and the collection rows).
#[qproperty(QString, add_json)]
type QbzMyQbzAdd = super::QbzMyQbzAddRust;
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
/// singleton; only QbzSession.boot also fires crate::on_boot).
#[qinvokable]
fn boot(self: Pin<&mut QbzMyQbzAdd>);
/// Open the picker for a JSON ARRAY of AddItem objects
/// (`{itemType, source, sourceItemId, title, subtitle?, artworkUrl?,
/// year?, trackCount?}`). An empty array is a no-op
/// (myqbz_add.rs:75-77). Plex rows pass `source: "local"` — there is no
/// "plex" source (myqbz_add.rs:30,58).
#[qinvokable]
fn open(self: Pin<&mut QbzMyQbzAdd>, items_json: QString);
/// Row click: insert every pending item into that collection with
/// `allow_duplicate = false`, then toast the outcome.
#[qinvokable]
fn pick(self: Pin<&mut QbzMyQbzAdd>, collection_id: QString);
/// Name substring filter — Rust re-filters its row cache and
/// republishes.
#[qinvokable]
fn search(self: Pin<&mut QbzMyQbzAdd>, query: QString);
/// Footer chip → create sub-panel (`kind` ∈ mixtape | collection).
#[qinvokable]
fn show_create(self: Pin<&mut QbzMyQbzAdd>, kind: QString);
/// Back out of the create sub-panel to the picker.
#[qinvokable]
fn create_back(self: Pin<&mut QbzMyQbzAdd>);
/// Create the collection, add the pending items, close, toast.
#[qinvokable]
fn create_and_add(self: Pin<&mut QbzMyQbzAdd>, kind: QString, name: QString);
/// Close and clear the pending items.
#[qinvokable]
fn close(self: Pin<&mut QbzMyQbzAdd>);
}
impl cxx_qt::Threading for QbzMyQbzAdd {}
}
use qbz_myqbz_add_bridge::QbzMyQbzAdd;
/// Rust side of the add-picker bridge (plain storage, phase-1 pattern).
pub struct QbzMyQbzAddRust {
add_json: QString,
}
impl Default for QbzMyQbzAddRust {
fn default() -> Self {
Self {
// Parseable default so QML's JSON.parse never throws on frame 1
// (home_bridge.rs:250-257).
add_json: QString::from("{}"),
}
}
}
// ---------------------------------------------------------------------------
// The UI hop (bridges/mod.rs pattern)
// ---------------------------------------------------------------------------
static QT_THREAD: OnceLock<CxxQtThread<QbzMyQbzAdd>> = OnceLock::new();
/// Queue an add-picker mutation onto the Qt event loop (no-op before boot
/// registers the thread).
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzMyQbzAdd>) + Send + 'static) {
if let Some(thread) = QT_THREAD.get() {
let _ = thread.queue(f);
}
}
impl qbz_myqbz_add_bridge::QbzMyQbzAdd {
pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() {
log::warn!("[qbz-qt] myqbz add Qt thread already registered");
}
// The picker is closed until a host opens it, and the closed document is
// already seeded in Default — nothing to publish here.
}
pub fn open(self: Pin<&mut Self>, items_json: QString) {
crate::myqbz_add_qt::open(&items_json.to_string());
}
pub fn pick(self: Pin<&mut Self>, collection_id: QString) {
crate::myqbz_add_qt::pick(&collection_id.to_string());
}
pub fn search(self: Pin<&mut Self>, query: QString) {
crate::myqbz_add_qt::search(&query.to_string());
}
pub fn show_create(self: Pin<&mut Self>, kind: QString) {
crate::myqbz_add_qt::show_create(&kind.to_string());
}
pub fn create_back(self: Pin<&mut Self>) {
crate::myqbz_add_qt::create_back();
}
pub fn create_and_add(self: Pin<&mut Self>, kind: QString, name: QString) {
crate::myqbz_add_qt::create_and_add(&kind.to_string(), &name.to_string());
}
pub fn close(self: Pin<&mut Self>) {
crate::myqbz_add_qt::close();
}
}
+671
View File
@@ -0,0 +1,671 @@
//! "Add to Mixtape/Collection" controller — the Slint-free port of
//! `crates/qbz/src/myqbz_add.rs` plus its `main.rs:5893-6015` action wiring.
//!
//! Every app-wide "Add to Mixtape/Collection" trigger hands this module a batch
//! of [`AddItem`]s (from QML as a JSON array through `QbzMyQbzAdd.open`, or
//! directly from a Rust seam such as the Local Library bulk bar). The batch is
//! held in a process-global PENDING store; the picker list is loaded
//! kind-restricted + recency-sorted + `item_exists`-resolved, and on pick /
//! create-and-add the items are written into the chosen collection through the
//! shared `qbz_mixtape::repo`.
//!
//! Dedup is the backend's job: `add_item_with(allow_duplicate = false)` returns
//! `Ok(false)` for an exact `(collection_id, source, source_item_id)` duplicate
//! — that is NOT an error. We count added vs skipped and surface the net result
//! as a toast, which for a fully-duplicate batch ("Already in {name}") is the
//! ONLY feedback the flow produces.
//!
//! Publishes ONE document, `addJson` (spec 02 §5.3), on its own singleton:
//! `QbzMyQbzAdd` is separate from `QbzMyQbz` because it is the only MyQBZ
//! surface other domains' QML touches (TrackRow, PlayerBar,
//! NowPlayingBarSmall, the Local Library bulk surfaces), so none of them can
//! couple to detail state.
//!
//! Threading: every DB touch runs inside `tokio::task::spawn_blocking`
//! (`LibraryDatabase` wraps a `!Send` rusqlite `Connection`), one open
//! connection per batch.
use std::sync::{LazyLock, Mutex};
use cxx_qt_lib::QString;
use qbz_models::mixtape::{AlbumSource, CollectionKind, ItemType, MixtapeCollection};
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Payload (spec 02 §5.3.1)
// ---------------------------------------------------------------------------
/// One pending item to add. Built by each call site from its row / album /
/// playlist data. `source_item_id` is ALWAYS a string (numeric ids are
/// stringified by the caller). Unknown keys are ignored and every optional key
/// deserializes to `None`.
#[derive(Clone, Debug, Deserialize)]
pub struct AddItem {
/// "album" | "track" | "playlist".
#[serde(rename = "itemType")]
pub item_type: String,
/// "qobuz" | "local" — Plex rows pass "local"; there is no "plex" source
/// (`myqbz_add.rs:30`, and `source_from_str` maps anything that is not
/// "local" to Qobuz).
pub source: String,
#[serde(rename = "sourceItemId")]
pub source_item_id: String,
pub title: String,
#[serde(default)]
pub subtitle: Option<String>,
#[serde(default, rename = "artworkUrl")]
pub artwork_url: Option<String>,
#[serde(default)]
pub year: Option<i32>,
#[serde(default, rename = "trackCount")]
pub track_count: Option<i32>,
}
/// Pending items for the currently-open picker. Set by [`open_items`], read by
/// the add / create handlers, cleared on [`close`].
static PENDING: LazyLock<Mutex<Vec<AddItem>>> = LazyLock::new(|| Mutex::new(Vec::new()));
/// Snapshot of the pending items (a clone — the store is cleared on close, not
/// on read, so a failed write can be retried from the same open modal).
fn pending_snapshot() -> Vec<AddItem> {
PENDING
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
fn item_type_from_str(s: &str) -> ItemType {
match s {
"track" => ItemType::Track,
"playlist" => ItemType::Playlist,
_ => ItemType::Album,
}
}
fn source_from_str(s: &str) -> AlbumSource {
match s {
"local" => AlbumSource::Local,
_ => AlbumSource::Qobuz,
}
}
// ---------------------------------------------------------------------------
// Document (spec 02 §5.3)
// ---------------------------------------------------------------------------
#[derive(Clone, Default, Serialize)]
pub struct AddRow {
pub id: String,
pub name: String,
/// "mixtape" | "collection" | "artist_collection".
pub kind: String,
/// "cassette" | "user" | "library-big" — resolved here, not in QML.
pub icon: String,
/// Translated, uppercase.
#[serde(rename = "kindLabel")]
pub kind_label: String,
/// "1 album" / "N albums".
pub meta: String,
/// True when EVERY pending item is already in this collection.
#[serde(rename = "alreadyHas")]
pub already_has: bool,
}
#[derive(Clone, Serialize)]
struct AddDoc {
open: bool,
loading: bool,
#[serde(rename = "headerTitle")]
header_title: String,
#[serde(rename = "headerSubtitle")]
header_subtitle: String,
#[serde(rename = "bulkMode")]
bulk_mode: bool,
/// True when ANY pending item is not an album (collections hold whole
/// albums only, so the list narrows to mixtapes).
#[serde(rename = "restrictToMixtape")]
restrict_to_mixtape: bool,
search: String,
/// The collection currently being written to ("" = idle).
#[serde(rename = "busyId")]
busy_id: String,
creating: bool,
/// "mixtape" | "collection".
#[serde(rename = "createKind")]
create_kind: String,
#[serde(rename = "createBusy")]
create_busy: bool,
rows: Vec<AddRow>,
}
impl Default for AddDoc {
fn default() -> Self {
Self {
open: false,
loading: false,
header_title: String::new(),
header_subtitle: String::new(),
bulk_mode: false,
restrict_to_mixtape: false,
search: String::new(),
busy_id: String::new(),
creating: false,
create_kind: "mixtape".to_string(),
create_busy: false,
rows: Vec::new(),
}
}
}
static DOC: LazyLock<Mutex<AddDoc>> = LazyLock::new(|| Mutex::new(AddDoc::default()));
/// Last-loaded picker rows, so a search re-filters client-side with no refetch.
static ROWS_CACHE: LazyLock<Mutex<Vec<LoadedRow>>> = LazyLock::new(|| Mutex::new(Vec::new()));
fn with_doc<R>(f: impl FnOnce(&mut AddDoc) -> R) -> R {
f(&mut DOC.lock().unwrap_or_else(|e| e.into_inner()))
}
fn publish() {
let json = with_doc(|doc| serde_json::to_string(doc).unwrap_or_else(|_| "{}".into()));
crate::myqbz_add_bridge::ui(move |mut b| {
b.as_mut().set_add_json(QString::from(json.as_str()));
});
}
// ---------------------------------------------------------------------------
// Row loading
// ---------------------------------------------------------------------------
/// A loaded picker row (the collection plus whether it already contains every
/// pending item). Built on a blocking worker by [`load_rows`].
pub struct LoadedRow {
pub id: String,
pub name: String,
pub kind: CollectionKind,
pub item_count: usize,
pub already_has: bool,
}
/// Load the collections offered as targets, kind-restricted + recency-sorted +
/// `item_exists`-resolved. BLOCKING (DB) — call from `spawn_blocking`.
///
/// - `restrict_to_mixtape` keeps `kind == mixtape` only (which also excludes
/// artist_collections);
/// - otherwise there is NO kind restriction at all: an album may be added to a
/// Collection AND to an artist_collection, so the user can augment a built
/// discography (`myqbz_add.rs:159-167`);
/// - sort = `last_played_at ?? updated_at` DESC;
/// - `already_has` = every pending item's `(source, source_item_id)` is already
/// in that collection.
pub(crate) fn load_rows(restrict_to_mixtape: bool, items: &[AddItem]) -> Vec<LoadedRow> {
crate::library_db_qt::with_db(false, |db| {
Ok(db.with_connection(|conn| {
let mut cols: Vec<MixtapeCollection> =
qbz_mixtape::repo::list_collections(conn, None).unwrap_or_else(|e| {
log::warn!("[qbz-qt] myqbz_add list_collections failed: {e}");
Vec::new()
});
if restrict_to_mixtape {
cols.retain(|c| c.kind == CollectionKind::Mixtape);
}
cols.sort_by(|a, b| {
let ra = a.last_played_at.unwrap_or(a.updated_at);
let rb = b.last_played_at.unwrap_or(b.updated_at);
rb.cmp(&ra)
});
cols.into_iter()
.map(|c| {
let already_has = !items.is_empty()
&& items.iter().all(|it| {
qbz_mixtape::repo::item_exists(
conn,
&c.id,
source_from_str(&it.source),
&it.source_item_id,
)
.unwrap_or(false)
});
LoadedRow {
id: c.id,
name: c.name,
kind: c.kind,
item_count: c.items.len(),
already_has,
}
})
.collect::<Vec<_>>()
}))
})
.unwrap_or_default()
}
fn kind_icon(kind: CollectionKind) -> &'static str {
match kind {
CollectionKind::Mixtape => "cassette",
CollectionKind::ArtistCollection => "user",
CollectionKind::Collection => "library-big",
}
}
fn kind_str(kind: CollectionKind) -> &'static str {
match kind {
CollectionKind::Mixtape => "mixtape",
CollectionKind::Collection => "collection",
CollectionKind::ArtistCollection => "artist_collection",
}
}
/// Rebuild the visible rows from the cache, honouring the search filter
/// (`myqbz_add.rs:239`).
fn rebuild() {
let cache = ROWS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
with_doc(|doc| {
let query = doc.search.trim().to_lowercase();
doc.rows = cache
.iter()
.filter(|r| query.is_empty() || r.name.to_lowercase().contains(&query))
.map(|r| AddRow {
id: r.id.clone(),
name: r.name.clone(),
kind: kind_str(r.kind).to_string(),
icon: kind_icon(r.kind).to_string(),
kind_label: crate::myqbz_qt::label_for(r.kind),
meta: crate::myqbz_qt::album_count_label(r.item_count),
already_has: r.already_has,
})
.collect();
});
}
// ---------------------------------------------------------------------------
// Open / close
// ---------------------------------------------------------------------------
/// Open the picker for a JSON **array** of [`AddItem`]s — the QML entry point
/// (`QbzMyQbzAdd.open(itemsJson)`). A malformed payload is logged and ignored.
pub(crate) fn open(items_json: &str) {
match serde_json::from_str::<Vec<AddItem>>(items_json) {
Ok(items) => open_items(items),
Err(e) => log::warn!("[qbz-qt] myqbz_add: bad items payload ({e}): {items_json}"),
}
}
/// Open the picker for one or more items. Empty input is a no-op (1:1 with
/// `openAddToMixtape([])`). Stores the payload, computes the header strings and
/// the kind restriction, marks loading, then loads the rows on a worker.
pub(crate) fn open_items(items: Vec<AddItem>) {
if items.is_empty() {
return;
}
let bulk = items.len() > 1;
// Restrict to mixtapes when ANY pending item is a track/playlist.
let restrict = items.iter().any(|it| it.item_type != "album");
let first_title = items[0].title.clone();
let first_subtitle = items[0].subtitle.clone().unwrap_or_default();
let header_title = if bulk {
qbz_i18n::tf(
"{} item",
"{} items",
items.len() as i64,
&[&items.len().to_string()],
)
} else {
first_title.clone()
};
let header_subtitle = if bulk {
let more = (items.len() - 1).to_string();
qbz_i18n::t_args("{} + {} more", &[&first_title, &more])
} else {
first_subtitle
};
*PENDING.lock().unwrap_or_else(|e| e.into_inner()) = items;
*ROWS_CACHE.lock().unwrap_or_else(|e| e.into_inner()) = Vec::new();
with_doc(|doc| {
doc.rows = Vec::new();
doc.header_title = header_title;
doc.header_subtitle = header_subtitle;
doc.bulk_mode = bulk;
doc.restrict_to_mixtape = restrict;
doc.search = String::new();
doc.busy_id = String::new();
doc.creating = false;
doc.create_kind = "mixtape".to_string();
doc.create_busy = false;
doc.loading = true;
doc.open = true;
});
publish();
let items = pending_snapshot();
crate::spawn(async move {
let rows = tokio::task::spawn_blocking(move || load_rows(restrict, &items))
.await
.unwrap_or_default();
*ROWS_CACHE.lock().unwrap_or_else(|e| e.into_inner()) = rows;
rebuild();
with_doc(|doc| doc.loading = false);
publish();
});
}
/// Close the picker and clear the pending payload.
pub(crate) fn close() {
PENDING.lock().unwrap_or_else(|e| e.into_inner()).clear();
with_doc(|doc| {
doc.open = false;
doc.creating = false;
doc.create_busy = false;
doc.busy_id = String::new();
});
publish();
}
/// Drop every per-user trace on logout — the pending batch (PENDING), the
/// loaded picker rows (ROWS_CACHE) and the modal document (DOC) — then publish
/// the cleared document so a picker left open cannot survive the user switch.
///
/// The Slint reference has NO MyQBZ teardown at all (`qbz/src/auth.rs:336-356`
/// tears down offline/fav_cache/reco/discover_prefs/blacklist/pinned/
/// local_favorites/search/lyrics and nothing MyQBZ), so this is not a parity
/// item — it is this port's own rule, already applied to the five siblings
/// called from `auth_qt::logout`. What it prevents is concrete: ROWS_CACHE
/// holds the previous account's collection ids and names, so the next account
/// would open the picker on a list of collections it does not own and `pick()`
/// would `add_item_with` against a foreign `collection_id`; PENDING would hand
/// that write the previous account's items.
pub(crate) fn teardown() {
PENDING.lock().unwrap_or_else(|e| e.into_inner()).clear();
ROWS_CACHE.lock().unwrap_or_else(|e| e.into_inner()).clear();
with_doc(|doc| *doc = AddDoc::default());
publish();
}
/// Re-filter the loaded rows client-side (no refetch).
pub(crate) fn search(query: &str) {
with_doc(|doc| doc.search = query.to_string());
rebuild();
publish();
}
/// Open the create sub-panel preset to a kind ("mixtape" | "collection").
pub(crate) fn show_create(kind: &str) {
with_doc(|doc| {
doc.create_kind = if kind == "collection" {
"collection".to_string()
} else {
"mixtape".to_string()
};
doc.create_busy = false;
doc.creating = true;
});
publish();
}
/// Back to the picker list.
pub(crate) fn create_back() {
with_doc(|doc| doc.creating = false);
publish();
}
// ---------------------------------------------------------------------------
// Add / create
// ---------------------------------------------------------------------------
/// How many rows were inserted and how many were skipped as duplicates.
pub struct AddOutcome {
pub added: usize,
pub skipped: usize,
}
/// Insert every pending item into `collection_id` with
/// `allow_duplicate = false`, ONE open connection for the whole batch.
/// BLOCKING (DB). `Ok(false)` from the repo is a dedup rejection, not an error;
/// a real `Err` is logged and the batch continues.
pub(crate) fn add_items(collection_id: &str, items: &[AddItem]) -> AddOutcome {
let mut added = 0usize;
let mut skipped = 0usize;
let _ = crate::library_db_qt::with_db(true, |db| {
Ok(db.with_connection(|conn| {
for it in items {
match qbz_mixtape::repo::add_item_with(
conn,
collection_id,
item_type_from_str(&it.item_type),
source_from_str(&it.source),
&it.source_item_id,
&it.title,
it.subtitle.as_deref(),
it.artwork_url.as_deref(),
it.year,
it.track_count,
false,
) {
Ok(true) => added += 1,
Ok(false) => skipped += 1,
Err(e) => log::warn!("[qbz-qt] myqbz_add add_item failed: {e}"),
}
}
}))
});
AddOutcome { added, skipped }
}
/// The add outcome as a toast (`myqbz_add.rs:313`). Three msgids are in play
/// and the "some skipped" arm NESTS one inside the other — it is not one flat
/// sentence.
fn toast_outcome(name: &str, outcome: &AddOutcome) {
if outcome.added == 0 {
// Nothing inserted -> everything was a duplicate. This Info toast is
// the ONLY signal the user gets that the add did nothing.
crate::toast_qt::info(qbz_i18n::t_args("Already in {}", &[name]));
return;
}
let msg = if outcome.skipped > 0 {
let skipped_label = qbz_i18n::tf(
"{} duplicate skipped",
"{} duplicates skipped",
outcome.skipped as i64,
&[&outcome.skipped.to_string()],
);
qbz_i18n::t_args(
"Added {} to {} ({})",
&[&outcome.added.to_string(), name, &skipped_label],
)
} else {
qbz_i18n::t_args("Added {} to {}", &[&outcome.added.to_string(), name])
};
crate::toast_qt::success(msg);
}
/// The chosen collection's display name, for the toast.
fn row_name(collection_id: &str) -> String {
ROWS_CACHE
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.find(|r| r.id == collection_id)
.map(|r| r.name.clone())
.unwrap_or_default()
}
/// Pick a target: add every pending item, toast the outcome, close.
/// Re-entrant clicks while a write is in flight are ignored.
pub(crate) fn pick(collection_id: &str) {
let accepted = with_doc(|doc| {
if !doc.busy_id.is_empty() {
return false;
}
doc.busy_id = collection_id.to_string();
true
});
if !accepted {
return;
}
publish();
let name = row_name(collection_id);
let items = pending_snapshot();
let cid = collection_id.to_string();
crate::spawn(async move {
let outcome = tokio::task::spawn_blocking(move || add_items(&cid, &items))
.await
.unwrap_or(AddOutcome {
added: 0,
skipped: 0,
});
toast_outcome(&name, &outcome);
close();
// `nav_qt::back()` never re-dispatches a load, so a landed mutation
// republishes both grids or the card's item count stays stale
// (spec 02 §7 T4).
if outcome.added > 0 {
crate::myqbz_qt::reload_grids();
}
});
}
/// Create a new manual collection of `kind` named `name`, returning
/// `(id, name)`. BLOCKING (DB). `"collection"` maps to `Collection`, anything
/// else to `Mixtape` — `artist_collection` is never creatable here.
pub(crate) fn create_collection(kind: &str, name: &str) -> Option<(String, String)> {
let kind = match kind {
"collection" => CollectionKind::Collection,
_ => CollectionKind::Mixtape,
};
crate::myqbz_qt::create_collection(kind, name).map(|c| (c.id, c.name))
}
/// Create then add: on success toast the outcome and close; on failure the
/// create panel STAYS open with `createBusy = false` and an error toast — the
/// raw English literal the reference uses (`main.rs:6009`, absent from all
/// eight catalogs, kept 1:1).
pub(crate) fn create_and_add(kind: &str, name: &str) {
let trimmed = name.trim().to_string();
let accepted = with_doc(|doc| {
if trimmed.is_empty() || doc.create_busy {
return false;
}
doc.create_busy = true;
true
});
if !accepted {
return;
}
publish();
let kind = kind.to_string();
let items = pending_snapshot();
crate::spawn(async move {
let created = tokio::task::spawn_blocking(move || create_collection(&kind, &trimmed))
.await
.ok()
.flatten();
match created {
Some((cid, cname)) => {
let outcome = tokio::task::spawn_blocking(move || add_items(&cid, &items))
.await
.unwrap_or(AddOutcome {
added: 0,
skipped: 0,
});
toast_outcome(&cname, &outcome);
close();
crate::myqbz_qt::reload_grids();
}
None => {
with_doc(|doc| doc.create_busy = false);
publish();
crate::toast_qt::error("Failed to create".to_string());
}
}
});
}
// ---------------------------------------------------------------------------
// Payload builders for the Rust-side seams
// ---------------------------------------------------------------------------
/// `track` payloads from a batch of LocalTracks (source "local" — Plex rows
/// included, since there is no "plex" source). Subtitle = "artist · album"; no
/// artwork / year / track count (`myqbz_add.rs:348`). Used by the Local
/// Library bulk-bar and folder-rail seams, which reach Rust rather than QML.
pub(crate) fn track_items_from_local(tracks: &[qbz_library::LocalTrack]) -> Vec<AddItem> {
tracks
.iter()
.map(|t| {
let subtitle = [t.artist.clone(), t.album.clone()]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" · ");
AddItem {
item_type: "track".into(),
source: "local".into(),
source_item_id: t.id.to_string(),
title: t.title.clone(),
subtitle: (!subtitle.is_empty()).then_some(subtitle),
artwork_url: None,
year: None,
track_count: None,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn payload_accepts_the_minimal_object_and_ignores_extras() {
let items: Vec<AddItem> = serde_json::from_str(
r#"[{"itemType":"album","source":"qobuz","sourceItemId":"123","title":"X",
"somethingElse":true}]"#,
)
.expect("minimal payload deserializes");
assert_eq!(items.len(), 1);
assert_eq!(items[0].item_type, "album");
assert!(items[0].subtitle.is_none());
assert!(items[0].year.is_none());
assert!(items[0].track_count.is_none());
}
#[test]
fn payload_reads_the_camelcase_optionals() {
let items: Vec<AddItem> = serde_json::from_str(
r#"[{"itemType":"track","source":"local","sourceItemId":"7","title":"T",
"subtitle":"A · B","artworkUrl":"/x.jpg","year":1999,"trackCount":12}]"#,
)
.expect("full payload deserializes");
let it = &items[0];
assert_eq!(it.source_item_id, "7");
assert_eq!(it.artwork_url.as_deref(), Some("/x.jpg"));
assert_eq!(it.year, Some(1999));
assert_eq!(it.track_count, Some(12));
assert_eq!(source_from_str(&it.source), AlbumSource::Local);
assert_eq!(item_type_from_str(&it.item_type), ItemType::Track);
}
#[test]
fn source_and_item_type_default_to_qobuz_album() {
assert_eq!(source_from_str("plex"), AlbumSource::Qobuz);
assert_eq!(source_from_str("anything"), AlbumSource::Qobuz);
assert_eq!(item_type_from_str("nonsense"), ItemType::Album);
assert_eq!(item_type_from_str("playlist"), ItemType::Playlist);
}
#[test]
fn kind_icons_match_the_reference() {
assert_eq!(kind_icon(CollectionKind::Mixtape), "cassette");
assert_eq!(kind_icon(CollectionKind::ArtistCollection), "user");
assert_eq!(kind_icon(CollectionKind::Collection), "library-big");
}
}
+560
View File
@@ -0,0 +1,560 @@
//! QbzMyQbz — MyQBZ domain bridge: both grids, the collection/mixtape detail
//! view and the three MyQBZ modals (create / edit / mix), plus the nav branding
//! document (phase-23 per-domain pattern; documented in main.rs).
//!
//! The module is `qbz_myqbz_bridge`, NEVER `qbz_mixtape`: bridge modules are
//! never named after a workspace crate — this crate depends on `qbz-mixtape`,
//! whose Rust name is `qbz_mixtape` (the `qbz_library` collision is the
//! documented precedent, library_bridge.rs:1-6). The QML type name comes from
//! the QObject (`QbzMyQbz`), not from the module, so QML is unaffected.
//!
//! Props: seven JSON documents, one per surface (spec 02 §4.1). TWO grid
//! properties on purpose: `nav_qt::back()` publishes only `currentView` and
//! never re-dispatches a load, so a single discriminated document would render
//! the Collections rows under the Mixtapes route after mixtapes → collections →
//! back. Two properties make that impossible and are 1:1 with Slint's two
//! models (`state.slint:2575,2578`).
//!
//! `branding_json` is seeded in `Default`, not at `boot()`: `NavFlyout.qml`
//! binds the section label and the first frame is already too late for a
//! post-boot push — the same reason `theme_json` / `window_width` are seeded at
//! construction (shell_bridge.rs "Seeded at CONSTRUCTION").
//!
//! No `#[qsignal]`: every user-visible outcome goes through `QbzShell.toastJson`
//! (toast_qt.rs) and every state change is a property republish, so the
//! auto-generated `<prop>Changed` signals are the only `Connections` targets QML
//! needs.
//!
//! Invokable bodies are one-line forwards into the domain controllers, exactly
//! as album_bridge.rs forwards. Nothing here holds state or does work.
use std::pin::Pin;
use std::sync::OnceLock;
use cxx_qt::CxxQtThread;
use cxx_qt::Threading as _;
use cxx_qt_lib::QString;
#[cxx_qt::bridge]
pub mod qbz_myqbz_bridge {
extern "C++" {
include!("cxx-qt-lib/qstring.h");
type QString = cxx_qt_lib::QString;
}
#[auto_cxx_name]
extern "RustQt" {
#[qobject]
#[qml_element]
#[qml_singleton]
// --- Grids (myqbz_qt.rs GridDoc; `loading`/`search`/`sort`/`sortDir`/
// `view`/`kindFilter` all live INSIDE the document) ---------------
#[qproperty(QString, mixtapes_json)]
#[qproperty(QString, collections_json)]
// --- Collection / mixtape detail (myqbz_detail_qt.rs DetailDoc) ------
#[qproperty(QString, detail_json)]
// --- Modals: create / edit (rename|description|delete) / DJ mix -----
#[qproperty(QString, create_json)]
#[qproperty(QString, edit_json)]
#[qproperty(QString, mix_json)]
// --- Nav branding (label + custom icon), seeded at construction -----
#[qproperty(QString, branding_json)]
type QbzMyQbz = super::QbzMyQbzRust;
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
/// singleton; only QbzSession.boot also fires crate::on_boot).
#[qinvokable]
fn boot(self: Pin<&mut QbzMyQbz>);
// --- Grids ----------------------------------------------------------
/// Grid card click → nav push + load the detail document.
#[qinvokable]
fn open_card(self: Pin<&mut QbzMyQbz>, id: QString);
/// `grid` is "mixtapes" | "collections" (the document's own identity).
#[qinvokable]
fn grid_search(self: Pin<&mut QbzMyQbz>, grid: QString, query: QString);
/// `field` ∈ position | name | items | updated; re-picking the same
/// field flips the direction, a new field resets to asc.
#[qinvokable]
fn grid_set_sort(self: Pin<&mut QbzMyQbz>, grid: QString, field: QString);
/// `view` ∈ grid | list (session-scoped, Rust-owned, not persisted).
#[qinvokable]
fn grid_set_view(self: Pin<&mut QbzMyQbz>, grid: QString, view: QString);
/// Collections grid only: all | collection | artist_collection.
#[qinvokable]
fn grid_set_kind_filter(self: Pin<&mut QbzMyQbz>, kind: QString);
/// Clears search + sort (+ the kind filter for collections).
#[qinvokable]
fn grid_reset(self: Pin<&mut QbzMyQbz>, grid: QString);
// --- Create modal ---------------------------------------------------
/// "+ New Mixtape" / "+ New Collection" (`kind` ∈ mixtape | collection).
#[qinvokable]
fn create_open(self: Pin<&mut QbzMyQbz>, kind: QString);
/// Radio flip inside the modal.
#[qinvokable]
fn create_set_kind(self: Pin<&mut QbzMyQbz>, kind: QString);
/// Rust caps the name at 80 Unicode scalars, creates, then navigates to
/// the new detail. The draft name lives in QML (`TextField.text`).
#[qinvokable]
fn create_submit(self: Pin<&mut QbzMyQbz>, name: QString);
#[qinvokable]
fn create_close(self: Pin<&mut QbzMyQbz>);
// --- Detail: toolbar ------------------------------------------------
/// Transient — the only detail mutation that does NOT persist prefs.
#[qinvokable]
fn detail_search(self: Pin<&mut QbzMyQbz>, query: QString);
/// `field` ∈ position | name | year | tracks.
#[qinvokable]
fn detail_set_sort(self: Pin<&mut QbzMyQbz>, field: QString);
/// `value` ∈ all | album | track | playlist.
#[qinvokable]
fn detail_set_type_filter(self: Pin<&mut QbzMyQbz>, value: QString);
/// Multi-select source filter: qobuz | plex | local.
#[qinvokable]
fn detail_toggle_source_filter(self: Pin<&mut QbzMyQbz>, kind: QString);
/// `mode` ∈ list | grid | expanded; `expanded` also fires
/// `ensure_expanded`. No re-derive — caches, selection and scroll
/// position all survive (myqbz_detail.rs:558-562).
#[qinvokable]
fn detail_set_view_mode(self: Pin<&mut QbzMyQbz>, mode: QString);
/// Leaving select mode clears the selection.
#[qinvokable]
fn detail_toggle_select_mode(self: Pin<&mut QbzMyQbz>);
/// `position` is the 0-based persisted position (the stable key).
#[qinvokable]
fn detail_toggle_item_select(self: Pin<&mut QbzMyQbz>, position: i32);
/// Resets type/source/sort; the search text is left INTACT
/// (myqbz_detail.rs:540-541).
#[qinvokable]
fn detail_reset_filters(self: Pin<&mut QbzMyQbz>);
// --- Detail: navigation out of a row --------------------------------
/// album/track → open_album; playlist → open_playlist.
#[qinvokable]
fn open_item(
self: Pin<&mut QbzMyQbz>,
source: QString,
item_type: QString,
source_item_id: QString,
);
/// qobuz + non-empty id → open_artist(id); qobuz + empty id → log and
/// ignore (never fall back to the name); local/plex → LocalLibrary
/// Artists by name.
#[qinvokable]
fn open_artist(
self: Pin<&mut QbzMyQbz>,
source: QString,
artist_name: QString,
artist_id: QString,
);
// --- Detail: hero actions -------------------------------------------
#[qinvokable]
fn play_all(self: Pin<&mut QbzMyQbz>);
#[qinvokable]
fn shuffle(self: Pin<&mut QbzMyQbz>);
/// Opens the DJ-mix ("Random queue") modal.
#[qinvokable]
fn dj_mix(self: Pin<&mut QbzMyQbz>);
/// `artist_collection` only, and a logged no-op by design:
/// artist_discography is a MARKER, there is no sync command
/// (myqbz_builder.rs:9-12, main.rs:6386-6392).
#[qinvokable]
fn sync(self: Pin<&mut QbzMyQbz>);
// --- Detail: per-row actions ----------------------------------------
#[qinvokable]
fn play_item(self: Pin<&mut QbzMyQbz>, source_item_id: QString);
/// `action` ∈ play | play-next | play-later | add-to-queue (the
/// underscore spellings are accepted too, myqbz_play.rs:59-67).
#[qinvokable]
fn item_action(self: Pin<&mut QbzMyQbz>, source_item_id: QString, action: QString);
/// Routes through the bulk remover with a 1-element vec.
#[qinvokable]
fn remove_item(self: Pin<&mut QbzMyQbz>, position: i32);
/// Bulk bar. Accepts, and the bar renders in this order:
/// add-to-queue · play-later · play-next · add-to-playlist ·
/// add-to-mixtape · remove-selected (danger) · clear
/// (MixtapeDetailView.slint:1258-1266 — note LATER before NEXT, the
/// opposite of the row menu).
#[qinvokable]
fn bulk_action(self: Pin<&mut QbzMyQbz>, id: QString);
/// Expanded mode: resolve the inline track lists (idempotent).
#[qinvokable]
fn ensure_expanded(self: Pin<&mut QbzMyQbz>);
/// `action` ∈ play | play-next | play-later | go-to-album.
#[qinvokable]
fn inline_track_action(
self: Pin<&mut QbzMyQbz>,
item_source_item_id: QString,
track_id: QString,
action: QString,
);
// --- Detail: overflow menu → edit modal / cover ----------------------
/// Fills `editJson.mode = "rename"`.
#[qinvokable]
fn open_rename(self: Pin<&mut QbzMyQbz>);
/// `mode = "description"`.
#[qinvokable]
fn open_description(self: Pin<&mut QbzMyQbz>);
/// `mode = "delete"`.
#[qinvokable]
fn open_delete(self: Pin<&mut QbzMyQbz>);
#[qinvokable]
fn upload_cover(self: Pin<&mut QbzMyQbz>);
#[qinvokable]
fn remove_cover(self: Pin<&mut QbzMyQbz>);
/// Reads the current mode from the Rust document, not from QML.
#[qinvokable]
fn toggle_play_mode(self: Pin<&mut QbzMyQbz>);
/// mixtape ⇄ collection; any other current kind is refused with a toast.
#[qinvokable]
fn convert_kind(self: Pin<&mut QbzMyQbz>);
// --- Edit modal submits ---------------------------------------------
/// Trim → an empty name closes without writing.
#[qinvokable]
fn edit_submit_rename(self: Pin<&mut QbzMyQbz>, name: QString);
/// Trimmed-empty → SQL NULL.
#[qinvokable]
fn edit_submit_description(self: Pin<&mut QbzMyQbz>, description: QString);
#[qinvokable]
fn edit_confirm_delete(self: Pin<&mut QbzMyQbz>);
#[qinvokable]
fn edit_close(self: Pin<&mut QbzMyQbz>);
// --- DJ-mix modal ---------------------------------------------------
/// Clamped to 0..len-1 by the controller.
#[qinvokable]
fn mix_set_index(self: Pin<&mut QbzMyQbz>, index: i32);
/// Confirm: dedup + hybrid sample + replace the queue.
#[qinvokable]
fn mix_shuffle(self: Pin<&mut QbzMyQbz>);
#[qinvokable]
fn mix_close(self: Pin<&mut QbzMyQbz>);
// --- Nav branding ---------------------------------------------------
/// Trimmed-empty coerces to "My QBZ" and PERSISTS that literal
/// (myqbz_prefs.rs:121-128).
#[qinvokable]
fn branding_set_label(self: Pin<&mut QbzMyQbz>, label: QString);
/// rfd picker (svg png jpg jpeg webp). Cancel ⇒ no-op, no toast.
#[qinvokable]
fn branding_pick_icon(self: Pin<&mut QbzMyQbz>);
/// Clears the stored path (does NOT store a default path).
#[qinvokable]
fn branding_reset_icon(self: Pin<&mut QbzMyQbz>);
}
impl cxx_qt::Threading for QbzMyQbz {}
}
use qbz_myqbz_bridge::QbzMyQbz;
/// Rust side of the MyQBZ bridge (plain storage, phase-1 pattern).
pub struct QbzMyQbzRust {
mixtapes_json: QString,
collections_json: QString,
detail_json: QString,
create_json: QString,
edit_json: QString,
mix_json: QString,
branding_json: QString,
}
impl Default for QbzMyQbzRust {
fn default() -> Self {
Self {
// Every document defaults to a PARSEABLE "{}" so QML's JSON.parse
// never throws on frame 1 (home_bridge.rs:250-257).
mixtapes_json: QString::from("{}"),
collections_json: QString::from("{}"),
detail_json: QString::from("{}"),
create_json: QString::from("{}"),
edit_json: QString::from("{}"),
mix_json: QString::from("{}"),
// Seeded at CONSTRUCTION, not at boot(): the nav catalog binds the
// section label on the very first frame. Before session activation
// this is the "My QBZ" default document.
branding_json: QString::from(crate::myqbz_prefs_qt::branding_json().as_str()),
}
}
}
// ---------------------------------------------------------------------------
// The UI hop (bridges/mod.rs pattern)
// ---------------------------------------------------------------------------
static QT_THREAD: OnceLock<CxxQtThread<QbzMyQbz>> = OnceLock::new();
/// Queue a MyQBZ-bridge mutation onto the Qt event loop (no-op before boot
/// registers the thread).
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzMyQbz>) + Send + 'static) {
if let Some(thread) = QT_THREAD.get() {
let _ = thread.queue(f);
}
}
impl qbz_myqbz_bridge::QbzMyQbz {
pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() {
log::warn!("[qbz-qt] myqbz Qt thread already registered");
}
// The grids are lazy per visit (navigate_to), the detail is opened by
// id and branding is already seeded, so there is nothing to publish
// here.
}
// --- Grids -------------------------------------------------------------
pub fn open_card(self: Pin<&mut Self>, id: QString) {
crate::myqbz_detail_qt::open(id.to_string());
}
pub fn grid_search(self: Pin<&mut Self>, grid: QString, query: QString) {
crate::myqbz_qt::grid_search(&grid.to_string(), &query.to_string());
}
pub fn grid_set_sort(self: Pin<&mut Self>, grid: QString, field: QString) {
crate::myqbz_qt::grid_set_sort(&grid.to_string(), &field.to_string());
}
pub fn grid_set_view(self: Pin<&mut Self>, grid: QString, view: QString) {
crate::myqbz_qt::grid_set_view(&grid.to_string(), &view.to_string());
}
pub fn grid_set_kind_filter(self: Pin<&mut Self>, kind: QString) {
crate::myqbz_qt::grid_set_kind_filter(&kind.to_string());
}
pub fn grid_reset(self: Pin<&mut Self>, grid: QString) {
crate::myqbz_qt::grid_reset(&grid.to_string());
}
// --- Create modal ------------------------------------------------------
pub fn create_open(self: Pin<&mut Self>, kind: QString) {
crate::myqbz_qt::create_open(&kind.to_string());
}
pub fn create_set_kind(self: Pin<&mut Self>, kind: QString) {
crate::myqbz_qt::create_set_kind(&kind.to_string());
}
pub fn create_submit(self: Pin<&mut Self>, name: QString) {
crate::myqbz_qt::create_submit(name.to_string());
}
pub fn create_close(self: Pin<&mut Self>) {
crate::myqbz_qt::create_close();
}
// --- Detail: toolbar ---------------------------------------------------
pub fn detail_search(self: Pin<&mut Self>, query: QString) {
crate::myqbz_detail_qt::search(&query.to_string());
}
pub fn detail_set_sort(self: Pin<&mut Self>, field: QString) {
crate::myqbz_detail_qt::set_sort(&field.to_string());
}
pub fn detail_set_type_filter(self: Pin<&mut Self>, value: QString) {
crate::myqbz_detail_qt::set_type_filter(&value.to_string());
}
pub fn detail_toggle_source_filter(self: Pin<&mut Self>, kind: QString) {
crate::myqbz_detail_qt::toggle_source_filter(&kind.to_string());
}
pub fn detail_set_view_mode(self: Pin<&mut Self>, mode: QString) {
crate::myqbz_detail_qt::set_view_mode(&mode.to_string());
}
pub fn detail_toggle_select_mode(self: Pin<&mut Self>) {
crate::myqbz_detail_qt::toggle_select_mode();
}
pub fn detail_toggle_item_select(self: Pin<&mut Self>, position: i32) {
crate::myqbz_detail_qt::toggle_item_select(position);
}
pub fn detail_reset_filters(self: Pin<&mut Self>) {
crate::myqbz_detail_qt::reset_filters();
}
// --- Detail: navigation out of a row -----------------------------------
pub fn open_item(
self: Pin<&mut Self>,
source: QString,
item_type: QString,
source_item_id: QString,
) {
crate::myqbz_detail_qt::open_item(
source.to_string(),
item_type.to_string(),
source_item_id.to_string(),
);
}
pub fn open_artist(
self: Pin<&mut Self>,
source: QString,
artist_name: QString,
artist_id: QString,
) {
crate::myqbz_detail_qt::open_artist(
source.to_string(),
artist_name.to_string(),
artist_id.to_string(),
);
}
// --- Detail: hero actions ----------------------------------------------
pub fn play_all(self: Pin<&mut Self>) {
crate::myqbz_play_qt::play_all();
}
pub fn shuffle(self: Pin<&mut Self>) {
crate::myqbz_play_qt::shuffle();
}
pub fn dj_mix(self: Pin<&mut Self>) {
crate::myqbz_mix_qt::open();
}
pub fn sync(self: Pin<&mut Self>) {
// 1:1 with main.rs:6386-6392 — the button exists, the command does not.
log::info!(
"[qbz-qt] myqbz sync requested: artist_discography is a marker only, \
there is no sync command (1:1 with the Slint no-op)"
);
}
// --- Detail: per-row actions -------------------------------------------
pub fn play_item(self: Pin<&mut Self>, source_item_id: QString) {
crate::myqbz_play_qt::play_item(source_item_id.to_string());
}
pub fn item_action(self: Pin<&mut Self>, source_item_id: QString, action: QString) {
crate::myqbz_play_qt::item_action(source_item_id.to_string(), action.to_string());
}
pub fn remove_item(self: Pin<&mut Self>, position: i32) {
crate::myqbz_edit_qt::remove_item(position);
}
pub fn bulk_action(self: Pin<&mut Self>, id: QString) {
crate::myqbz_detail_qt::bulk_action(id.to_string());
}
pub fn ensure_expanded(self: Pin<&mut Self>) {
crate::myqbz_detail_qt::ensure_expanded();
}
pub fn inline_track_action(
self: Pin<&mut Self>,
item_source_item_id: QString,
track_id: QString,
action: QString,
) {
crate::myqbz_play_qt::play_inline_track(
item_source_item_id.to_string(),
track_id.to_string(),
action.to_string(),
);
}
// --- Detail: overflow menu → edit modal / cover -------------------------
pub fn open_rename(self: Pin<&mut Self>) {
crate::myqbz_edit_qt::open_rename();
}
pub fn open_description(self: Pin<&mut Self>) {
crate::myqbz_edit_qt::open_description();
}
pub fn open_delete(self: Pin<&mut Self>) {
crate::myqbz_edit_qt::open_delete();
}
pub fn upload_cover(self: Pin<&mut Self>) {
crate::myqbz_cover_qt::upload();
}
pub fn remove_cover(self: Pin<&mut Self>) {
crate::myqbz_cover_qt::remove();
}
pub fn toggle_play_mode(self: Pin<&mut Self>) {
crate::myqbz_edit_qt::toggle_play_mode();
}
pub fn convert_kind(self: Pin<&mut Self>) {
crate::myqbz_edit_qt::convert_kind();
}
// --- Edit modal submits ------------------------------------------------
pub fn edit_submit_rename(self: Pin<&mut Self>, name: QString) {
crate::myqbz_edit_qt::submit_rename(name.to_string());
}
pub fn edit_submit_description(self: Pin<&mut Self>, description: QString) {
crate::myqbz_edit_qt::submit_description(description.to_string());
}
pub fn edit_confirm_delete(self: Pin<&mut Self>) {
crate::myqbz_edit_qt::confirm_delete();
}
pub fn edit_close(self: Pin<&mut Self>) {
crate::myqbz_edit_qt::close();
}
// --- DJ-mix modal ------------------------------------------------------
pub fn mix_set_index(self: Pin<&mut Self>, index: i32) {
crate::myqbz_mix_qt::apply_index(index);
}
pub fn mix_shuffle(self: Pin<&mut Self>) {
crate::myqbz_mix_qt::confirm();
}
pub fn mix_close(self: Pin<&mut Self>) {
crate::myqbz_mix_qt::close();
}
// --- Nav branding ------------------------------------------------------
pub fn branding_set_label(self: Pin<&mut Self>, label: QString) {
crate::myqbz_prefs_qt::set_label(&label.to_string());
}
pub fn branding_pick_icon(self: Pin<&mut Self>) {
crate::myqbz_prefs_qt::pick_icon();
}
pub fn branding_reset_icon(self: Pin<&mut Self>) {
crate::myqbz_prefs_qt::reset_icon();
}
}
+235
View File
@@ -0,0 +1,235 @@
//! Discography Builder — the THREE source fetchers, split out of
//! `myqbz_builder_qt.rs` (which crossed the ~1200-line mandatory-split line of
//! the domain recipe, §T3). The classifier, the grouping, the selection maths
//! and the save flow stay in the parent module on purpose: they are one
//! algorithm and splitting mid-algorithm buys nothing (spec 02 §3).
//!
//! Both fetchers produce `myqbz_builder_qt::Candidate` and reuse the parent's
//! classifier (`normalize_title` / `title_is_compilation` / `classify_release` /
//! `quality_score`) — there is no second copy of any of it here.
//!
//! Ordering is load-bearing: Qobuz runs FIRST because it resolves the artist
//! NAME, and the local/Plex query filters on that name. A Qobuz failure still
//! allows the local half if a name is known; a DB failure degrades to empty and
//! the Qobuz half still works.
use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use qbz_library::album_grouping::AlbumGroupMode;
use crate::myqbz_builder_qt::{
classify_release, normalize_title, quality_score, title_is_compilation, Candidate,
};
/// Fetch the artist's Qobuz releases. Returns `(candidates, artist_name,
/// avatar_url)`. Mirrors `fetchQobuzAlbums` — name + avatar are a side effect.
/// The avatar url is the MEDIUM portrait variant (the header renders a 72px
/// circle), 1:1 with `myqbz_builder.rs:316-323`.
pub async fn fetch_qobuz(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
artist_id: &str,
) -> Result<(Vec<Candidate>, String, String), String> {
let id: u64 = artist_id
.parse()
.map_err(|_| format!("invalid artist id: {artist_id}"))?;
let page = runtime
.core()
.get_artist_page(id, None)
.await
.map_err(|e| e.to_string())?;
let artist_name = page.name.display.clone();
let avatar_url = page
.images
.as_ref()
.and_then(|imgs| imgs.portrait.as_ref())
.map(|p| {
format!(
"https://static.qobuz.com/images/artists/covers/medium/{}.{}",
p.hash, p.format
)
})
.unwrap_or_default();
let mut out: Vec<Candidate> = Vec::new();
for group in page.releases.into_iter().flatten() {
let group_type = group.release_type.clone();
for r in group.items.into_iter() {
let year = r
.dates
.as_ref()
.and_then(|d| d.original.as_deref())
.and_then(|s| s.get(..4))
.and_then(|y| y.parse::<i32>().ok());
let artwork = r
.image
.as_ref()
.and_then(|img| img.large.clone().or_else(|| img.best().cloned()));
let track_count = r.tracks_count.map(|n| n as i32);
let bit = r.audio_info.as_ref().and_then(|a| a.maximum_bit_depth);
let rate = r.audio_info.as_ref().and_then(|a| a.maximum_sampling_rate);
let title = r.title.clone();
let title_comp = title_is_compilation(&title);
let release_type = classify_release(
&title,
track_count,
r.release_type.as_deref(),
Some(&group_type),
title_comp,
);
let is_comp = release_type == "compilation";
let group_key = format!(
"{}|{}",
normalize_title(&title),
year.map(|y| y.to_string()).unwrap_or_default()
);
out.push(Candidate {
group_key,
source: "qobuz".to_string(),
source_item_id: r.id.clone(),
title,
artist: artist_name.clone(),
year,
artwork_url: artwork,
artwork_path: String::new(),
track_count,
max_bit_depth: bit,
max_sample_rate: rate,
format: "FLAC".to_string(),
is_compilation: is_comp,
release_type,
quality_score: quality_score(bit, rate, "FLAC"),
});
}
}
Ok((out, artist_name, avatar_url))
}
/// Whether a local album's `artist` / `all_artists` matches `artist_name`
/// (case-insensitive exact match on artist OR any comma-split all_artists entry).
fn matches_artist(artist: &str, all_artists: &str, artist_name: &str) -> bool {
let needle = artist_name.trim().to_lowercase();
if needle.is_empty() {
return false;
}
if artist.trim().to_lowercase() == needle {
return true;
}
all_artists
.split(',')
.any(|a| a.trim().to_lowercase() == needle)
}
/// Fetch local + Plex albums by the artist via the unified metadata-grouped
/// page (Plex union included when enabled). BLOCKING (DB) — the caller wraps it
/// in `spawn_blocking`. Degrades to empty on DB failure (logged by `with_db`).
/// `artist_name` MUST be resolved first (the Qobuz fetch sets it) — an empty
/// name drops every match.
pub fn fetch_local_and_plex(artist_name: &str) -> Vec<Candidate> {
if artist_name.trim().is_empty() {
return Vec::new();
}
let plex_path = crate::local_plex::cache_db_path();
// Map INSIDE the `with_db` closure so `db.resolve_album_cover_fallback` is
// reachable (mirrors the Albums grid): the cover PATH rides on the
// candidate's `artwork_url`, so the saved collection item carries it and the
// detail rows render the real cover instead of the disc placeholder. Plex
// rows arrive with a non-empty `/library/...` thumb path; local rows carry
// `a.artwork_path`, with the same cover.jpg/folder.jpg on-disk fallback the
// grid uses so a DB row missing artwork_path still resolves a cover.
crate::local_state::with_db(move |db| {
let page = db.get_albums_metadata_page(
0,
1_000_000,
None,
"artist",
"asc",
/* include_qobuz_downloads */ true,
// GAP: the reference passes `local_library::exclude_network_folders_now()`
// (qbz/src/local_library.rs). This port has no equivalent and every
// other call site hardcodes `false` (local_albums.rs:42,70,94,173);
// the builder must see the SAME set the Albums tab shows.
/* exclude_network_folders */ false,
plex_path.as_deref(),
// My QBZ collections are artist-scoped CANDIDATES, not the Albums
// view — keep the metadata grouping regardless of the user's Local
// Library identity-mode pref.
AlbumGroupMode::Metadata,
)?;
let out: Vec<Candidate> = page
.albums
.into_iter()
.filter(|a| matches_artist(&a.artist, &a.all_artists, artist_name))
.map(|a| {
let source = if a.source == "plex" { "plex" } else { "local" }.to_string();
let year = a.year.map(|y| y as i32);
let track_count = if a.track_count > 0 {
Some(a.track_count as i32)
} else {
None
};
let bit = a.bit_depth;
// LocalAlbum.sample_rate is Hz; the candidate carries kHz.
let rate_khz = if a.sample_rate >= 1000.0 {
Some(a.sample_rate / 1000.0)
} else if a.sample_rate > 0.0 {
Some(a.sample_rate)
} else {
None
};
// Cover path: the row's own artwork_path, else the on-disk
// cover.jpg/folder.jpg fallback (local only — Plex rows already
// carry a non-empty thumb path so the fallback no-ops).
let artwork_url = a
.artwork_path
.clone()
.filter(|p| !p.is_empty())
.or_else(|| db.resolve_album_cover_fallback(&a.id));
let format = a.format.to_string();
let title = a.title.clone();
let title_comp = title_is_compilation(&title);
let release_type = classify_release(&title, track_count, None, None, title_comp);
let is_comp = release_type == "compilation";
let group_key = format!(
"{}|{}",
normalize_title(&title),
year.map(|y| y.to_string()).unwrap_or_default()
);
Candidate {
group_key,
source,
source_item_id: a.id,
title,
artist: a.artist,
year,
artwork_url,
artwork_path: String::new(),
track_count,
max_bit_depth: bit,
max_sample_rate: rate_khz,
quality_score: quality_score(bit, rate_khz, &format),
format,
is_compilation: is_comp,
release_type,
}
})
.collect();
Ok(out)
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::matches_artist;
#[test]
fn matches_artist_is_case_insensitive_over_both_columns() {
assert!(matches_artist("Radiohead", "", "radiohead"));
assert!(matches_artist("Various", "Foo, Radiohead , Bar", "RADIOHEAD"));
assert!(!matches_artist("Radioheads", "", "radiohead"));
assert!(!matches_artist("Radiohead", "", " "));
}
}
File diff suppressed because it is too large. Load diff
+233
View File
@@ -0,0 +1,233 @@
//! My QBZ — custom-cover upload / remove. The Qt port of
//! `crates/qbz/src/myqbz_cover.rs` (spec 02 §6.5).
//!
//! The STEP ORDER is load-bearing — a reordering silently loses the previous
//! file or orphans the new one:
//!
//! 1. native picker (cancel ⇒ return, NO toast)
//! 2. source must exist; extension ∈ png/jpg/jpeg/webp (lowercased)
//! 3. read the previous `custom_artwork_path` (deleted AFTER the persist)
//! 4. `dest = artwork_cache_dir/mixtape_custom_{safe_id}_{epoch_SECS}.jpg`
//! 5. decode → `resize(1000, 1000, Lanczos3)` → save as JPEG
//! 6. persist the new path; on failure delete the orphan we just wrote
//! 7. delete the previous file ONLY when it differs from dest
//! 8. success toast + reload; on error `log::warn!` + error toast and NO reload
//!
//! NOTE on `.webp`: this crate's `image` is built with `jpeg` + `png` only, so a
//! `.webp` source decodes to an error at runtime. The extension is still
//! ACCEPTED, to match the picker filter, and the decode failure surfaces the
//! "Failed to upload cover" toast. That is the reference's behaviour — do not
//! "fix" it (spec 02 §2.2).
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use image::imageops::FilterType;
/// The four extensions the picker accepts.
const ALLOWED_EXTENSIONS: [&str; 4] = ["png", "jpg", "jpeg", "webp"];
/// Epoch SECONDS, not millis.
fn epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Keep ascii-alphanumeric / `-` / `_`; everything else becomes `_`. The
/// collection id is a UUID, so this is normally a no-op.
fn safe_id(id: &str) -> String {
id.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
/// Read the stored `custom_artwork_path` (to delete the previous file after the
/// new one persists). SYNCHRONOUS — called inside `spawn_blocking`.
fn get_prev_path(id: &str) -> Option<String> {
crate::library_db_qt::with_db(false, |db| {
Ok(db.with_connection(|conn| {
qbz_mixtape::repo::get_custom_artwork(conn, id).unwrap_or(None)
}))
})
.flatten()
}
/// Persist `path` (or clear it with `None`). True on success.
fn set_custom_artwork(id: &str, path: Option<&str>) -> bool {
let id = id.to_string();
let path = path.map(|p| p.to_string());
crate::library_db_qt::with_db(true, move |db| {
db.with_connection(|conn| {
qbz_mixtape::repo::set_custom_artwork(conn, &id, path.as_deref())
})
.map_err(|e| {
qbz_library::LibraryError::Database(format!("set_custom_artwork failed: {e}"))
})
})
.is_some()
}
/// Decode, resize to 1000×1000 (Lanczos3) and save as a JPEG.
fn resize_and_save(source: &Path, dest: &Path) -> Result<(), String> {
let img = image::open(source).map_err(|e| format!("decode failed: {e}"))?;
let resized = img.resize(1000, 1000, FilterType::Lanczos3);
resized
.to_rgb8()
.save_with_format(dest, image::ImageFormat::Jpeg)
.map_err(|e| format!("save failed: {e}"))
}
/// The blocking upload body. Returns the persisted destination path.
fn do_upload(id: &str, source_path: &str) -> Result<String, String> {
let source = PathBuf::from(source_path);
if !source.exists() {
return Err("source file not found".to_string());
}
// 1 — extension validation.
let ext_ok = source
.extension()
.and_then(|e| e.to_str())
.map(|e| ALLOWED_EXTENSIONS.contains(&e.to_lowercase().as_str()))
.unwrap_or(false);
if !ext_ok {
return Err("unsupported image type".to_string());
}
// 2 — previous path, deleted only AFTER the persist lands.
let prev = get_prev_path(id);
// 3 — destination in the shared artwork cache dir.
let artwork_dir = qbz_library::get_artwork_cache_dir();
let filename = format!("mixtape_custom_{}_{}.jpg", safe_id(id), epoch_secs());
let dest = artwork_dir.join(&filename);
// 4 — decode + resize + save.
resize_and_save(&source, &dest)?;
// 5 — persist.
let dest_str = dest.to_string_lossy().to_string();
if !set_custom_artwork(id, Some(&dest_str)) {
// Persist failed — clean up the orphan we just wrote.
let _ = std::fs::remove_file(&dest);
return Err("failed to save cover".to_string());
}
// 6 — delete the previous file, only if it differs.
if let Some(prev) = prev {
if prev != dest_str {
let _ = std::fs::remove_file(&prev);
}
}
Ok(dest_str)
}
/// The blocking remove body: read previous → clear → delete prev.
fn do_remove(id: &str) -> Result<(), String> {
let prev = get_prev_path(id);
if !set_custom_artwork(id, None) {
return Err("failed to clear cover".to_string());
}
if let Some(prev) = prev {
let _ = std::fs::remove_file(&prev);
}
Ok(())
}
/// Reload the open detail so the hero picks up the new cover, and re-list the
/// grids so their mosaic / custom-cover card agrees (spec 02 §7 T4).
fn reload(id: String) {
crate::myqbz_detail_qt::reload(id);
crate::myqbz_qt::reload_grids();
}
/// Hero overflow "Set custom cover": native picker, then upload + resize +
/// persist + reload.
pub(crate) fn upload() {
let id = crate::myqbz_detail_qt::current_id();
if id.is_empty() {
return;
}
crate::spawn(async move {
let Some(file) = rfd::AsyncFileDialog::new()
.set_title(&qbz_i18n::t("Choose a cover image"))
.add_filter(&qbz_i18n::t("Image"), &["png", "jpg", "jpeg", "webp"])
.pick_file()
.await
else {
return; // cancelled — no toast.
};
let source = file.path().to_string_lossy().to_string();
let upload_id = id.clone();
let result = tokio::task::spawn_blocking(move || do_upload(&upload_id, &source))
.await
.unwrap_or_else(|e| Err(format!("upload task panicked: {e}")));
match result {
Ok(_) => {
crate::toast_qt::success(qbz_i18n::t("Cover updated"));
reload(id);
}
Err(e) => {
log::warn!("[qbz-qt] myqbz_cover upload failed: {e}");
crate::toast_qt::error(qbz_i18n::t("Failed to upload cover"));
}
}
});
}
/// Hero overflow "Remove custom cover": clear + delete the file + reload.
pub(crate) fn remove() {
let id = crate::myqbz_detail_qt::current_id();
if id.is_empty() {
return;
}
crate::spawn(async move {
let remove_id = id.clone();
let result = tokio::task::spawn_blocking(move || do_remove(&remove_id))
.await
.unwrap_or_else(|e| Err(format!("remove task panicked: {e}")));
match result {
Ok(()) => {
crate::toast_qt::success(qbz_i18n::t("Cover removed"));
reload(id);
}
Err(e) => {
log::warn!("[qbz-qt] myqbz_cover remove failed: {e}");
crate::toast_qt::error(qbz_i18n::t("Failed to remove cover"));
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_id_keeps_uuid_characters_and_scrubs_the_rest() {
assert_eq!(
safe_id("7f3c9a1e-2b4d-4c8f-9e1a-0d5b6c7a8f90"),
"7f3c9a1e-2b4d-4c8f-9e1a-0d5b6c7a8f90"
);
assert_eq!(safe_id("a/b c.d"), "a_b_c_d");
}
#[test]
fn extension_allow_list_is_case_insensitive_and_includes_webp() {
for ext in ["PNG", "Jpg", "jpeg", "WEBP"] {
assert!(ALLOWED_EXTENSIONS.contains(&ext.to_lowercase().as_str()));
}
assert!(!ALLOWED_EXTENSIONS.contains(&"gif"));
}
}
File diff suppressed because it is too large. Load diff
+431
View File
@@ -0,0 +1,431 @@
//! My QBZ — collection detail EDIT operations. The Qt port of
//! `crates/qbz/src/myqbz_edit.rs` (spec 02 §6.4), publishing the small
//! `QbzMyQbz.editJson` modal document (spec 02 §5.5).
//!
//! Wires the hero overflow (⋯) menu + the Rename / Description /
//! Delete-confirm panels to the shared `qbz_mixtape::repo` setters, reached
//! through `library_db_qt::with_db(true, …)` — the only CREATE-capable
//! accessor, which a fresh account needs (spec 02 §2.1).
//!
//! Every mutation then RELOADS the open detail (the reference's "-> reload") so
//! the hero + toolbar reflect the change, and re-lists BOTH grids
//! (`myqbz_qt::reload_grids`): `nav_qt::back()` in this port publishes only
//! `currentView` and never re-dispatches a load, so a user pressing BACK after a
//! rename / convert / bulk-remove would otherwise land on a stale grid card
//! (spec 02 §7 T4).
//!
//! All DB work runs inside `spawn_blocking` (T1); the toast + reload hop back
//! through their own publishers.
use serde::Serialize;
use cxx_qt_lib::QString;
use qbz_models::mixtape::{CollectionKind, CollectionPlayMode};
// ---------------------------------------------------------------------------
// Document (spec 02 §5.5)
// ---------------------------------------------------------------------------
#[derive(Clone, Default, Serialize)]
pub struct EditDoc {
/// "" | "rename" | "description" | "delete".
pub mode: String,
pub open: bool,
/// Rename prefill AND the delete-confirm interpolation.
pub name: String,
/// Description prefill.
pub description: String,
pub busy: bool,
/// Aliases — spec 01 §13.1 spells the two prefills
/// `draftName` / `draftDescription`. Filled in `publish`, so either QML
/// spelling resolves.
#[serde(rename = "draftName")]
pub draft_name: String,
#[serde(rename = "draftDescription")]
pub draft_description: String,
}
static DOC: std::sync::Mutex<Option<EditDoc>> = std::sync::Mutex::new(None);
fn with_doc<R>(f: impl FnOnce(&mut EditDoc) -> R) -> R {
let mut guard = DOC.lock().unwrap();
let doc = guard.get_or_insert_with(EditDoc::default);
f(doc)
}
fn publish(doc: &EditDoc) {
let mut out = doc.clone();
out.draft_name = out.name.clone();
out.draft_description = out.description.clone();
let json = serde_json::to_string(&out).unwrap_or_else(|_| "{}".into());
crate::myqbz_bridge::ui(move |mut b| {
b.as_mut().set_edit_json(QString::from(json.as_str()));
});
}
fn set_busy(busy: bool) {
let doc = with_doc(|d| {
d.busy = busy;
d.clone()
});
publish(&doc);
}
/// Close the modal (clears mode + busy).
pub(crate) fn close() {
let doc = with_doc(|d| {
d.open = false;
d.mode = String::new();
d.busy = false;
d.clone()
});
publish(&doc);
}
// NO `republish()` here, deliberately. `main.rs::apply_language` republishes
// `myqbz_qt` / `myqbz_detail_qt` / `myqbz_builder_qt` because those documents
// carry Rust-BUILT translated strings (the kind eyebrow, the "{} albums" plural,
// unresolved rows' TYPE label, the builder's footer). `EditDoc` carries none —
// `mode`, `name`, `description` and `busy` are all data, and every visible label
// in the panels is a QML `QbzSession.tr(msgid, trRev)` binding that re-evaluates
// itself. The reference agrees: its language switch (`reseed_i18n_labels`,
// `qbz/src/main.rs:4982`) reseeds only the AppearanceState string arrays and
// never touches `MyQbzEditState`.
/// Drop the modal state on logout.
pub(crate) fn teardown() {
let doc = with_doc(|d| {
*d = EditDoc::default();
d.clone()
});
publish(&doc);
}
// ---------------------------------------------------------------------------
// Panel openers — the prefills come from the RUST document, never from QML
// ---------------------------------------------------------------------------
fn open_panel(mode: &str) {
let name = crate::myqbz_detail_qt::current_name();
let description = crate::myqbz_detail_qt::current_description();
let doc = with_doc(|d| {
d.mode = mode.to_string();
d.open = true;
d.busy = false;
d.name = name.clone();
d.description = description.clone();
d.clone()
});
publish(&doc);
}
pub(crate) fn open_rename() {
open_panel("rename");
}
pub(crate) fn open_description() {
open_panel("description");
}
pub(crate) fn open_delete() {
open_panel("delete");
}
// ---------------------------------------------------------------------------
// DB write helper
// ---------------------------------------------------------------------------
/// Run one repo mutation against the per-user library.db. `Err` on any failure
/// (DB unavailable or the repo error). SYNCHRONOUS — always called inside
/// `spawn_blocking`.
fn write_repo<F>(f: F) -> Result<(), String>
where
F: FnOnce(&qbz_library::LibraryDatabase) -> Result<(), String>,
{
crate::library_db_qt::with_db(true, |db| Ok(f(db)))
.unwrap_or_else(|| Err("library database unavailable".to_string()))
}
// ---------------------------------------------------------------------------
// Reload after a landed mutation
// ---------------------------------------------------------------------------
/// The reference's "-> reload": re-run the detail load for the same id WITHOUT
/// a second nav record, and re-list both grids (T4).
fn reload(id: String) {
crate::myqbz_detail_qt::reload(id);
crate::myqbz_qt::reload_grids();
}
/// On success: optional success toast, close the modal, reload. On failure:
/// clear busy + error toast (the modal stays open).
fn finish(id: String, result: Result<(), String>, success_toast: Option<String>, err_msg: String) {
match result {
Ok(()) => {
if let Some(msg) = success_toast {
crate::toast_qt::success(msg);
}
close();
reload(id);
}
Err(e) => {
log::warn!("[qbz-qt] myqbz_edit mutation failed: {e}");
set_busy(false);
crate::toast_qt::error(err_msg);
}
}
}
// ---------------------------------------------------------------------------
// Mutations
// ---------------------------------------------------------------------------
/// Rename submit: trim + cap at 80 Unicode scalars; trimmed-empty closes
/// WITHOUT writing.
pub(crate) fn submit_rename(raw_name: String) {
let id = crate::myqbz_detail_qt::current_id();
let name: String = raw_name.trim().chars().take(80).collect();
if id.is_empty() || name.is_empty() {
close();
return;
}
set_busy(true);
crate::spawn(async move {
let write_id = id.clone();
let write_name = name.clone();
let result = tokio::task::spawn_blocking(move || {
write_repo(|db| {
db.with_connection(|conn| {
qbz_mixtape::repo::rename_collection(conn, &write_id, &write_name)
})
.map_err(|e| e.to_string())
})
})
.await
.unwrap_or_else(|e| Err(format!("rename task panicked: {e}")));
finish(id, result, None, qbz_i18n::t("Failed to rename"));
});
}
/// Description submit: trimmed-empty ⇒ SQL NULL (clear).
pub(crate) fn submit_description(raw_description: String) {
let id = crate::myqbz_detail_qt::current_id();
if id.is_empty() {
close();
return;
}
let trimmed = raw_description.trim().to_string();
let desc: Option<String> = if trimmed.is_empty() {
None
} else {
Some(trimmed)
};
set_busy(true);
crate::spawn(async move {
let write_id = id.clone();
let write_desc = desc.clone();
let result = tokio::task::spawn_blocking(move || {
write_repo(|db| {
db.with_connection(|conn| {
qbz_mixtape::repo::set_description(conn, &write_id, write_desc.as_deref())
})
.map_err(|e| e.to_string())
})
})
.await
.unwrap_or_else(|e| Err(format!("description task panicked: {e}")));
finish(id, result, None, qbz_i18n::t("Failed to save description"));
});
}
/// Hero overflow play-mode toggle: `in_order` ⇒ `AlbumShuffle`, anything else ⇒
/// `InOrder`. The CURRENT mode is read from the Rust document, not from QML.
pub(crate) fn toggle_play_mode() {
let id = crate::myqbz_detail_qt::current_id();
if id.is_empty() {
return;
}
let next = if crate::myqbz_detail_qt::current_play_mode() == "in_order" {
CollectionPlayMode::AlbumShuffle
} else {
CollectionPlayMode::InOrder
};
crate::spawn(async move {
let write_id = id.clone();
let result = tokio::task::spawn_blocking(move || {
write_repo(|db| {
db.with_connection(|conn| {
qbz_mixtape::repo::set_play_mode(conn, &write_id, next)
})
.map_err(|e| e.to_string())
})
})
.await
.unwrap_or_else(|e| Err(format!("play-mode task panicked: {e}")));
finish(id, result, None, qbz_i18n::t("Failed to change play mode"));
});
}
/// Hero overflow convert-kind: `mixtape` ⇄ `collection`. Any other current kind
/// (i.e. `artist_collection`) toasts immediately and writes NOTHING — the repo
/// rejects it either way.
pub(crate) fn convert_kind() {
let id = crate::myqbz_detail_qt::current_id();
if id.is_empty() {
return;
}
let next = match crate::myqbz_detail_qt::current_kind().as_str() {
"mixtape" => CollectionKind::Collection,
"collection" => CollectionKind::Mixtape,
_ => {
crate::toast_qt::error(qbz_i18n::t("Cannot convert this kind"));
return;
}
};
crate::spawn(async move {
let write_id = id.clone();
let result = tokio::task::spawn_blocking(move || {
write_repo(|db| {
db.with_connection(|conn| qbz_mixtape::repo::set_kind(conn, &write_id, next))
.map_err(|e| e.to_string())
})
})
.await
.unwrap_or_else(|e| Err(format!("convert-kind task panicked: {e}")));
match result {
Ok(()) => {
crate::toast_qt::success(qbz_i18n::t("Converted"));
// convert_kind MOVES the row between the two grids, so both
// must republish (T4).
reload(id);
}
Err(e) => {
log::warn!("[qbz-qt] myqbz_edit convert-kind failed: {e}");
// The repo's only rejection here is the artist_collection guard.
crate::toast_qt::error(qbz_i18n::t("Cannot convert this kind"));
}
}
});
}
/// Delete-confirm: `repo::delete_collection` (CASCADE). On success the order is
/// exactly: close the modal → drop the stored view-prefs key → `nav_qt::back()`
/// → re-list the grids. On error the modal STAYS open with `busy = false`.
pub(crate) fn confirm_delete() {
let id = crate::myqbz_detail_qt::current_id();
if id.is_empty() {
close();
return;
}
set_busy(true);
crate::spawn(async move {
let write_id = id.clone();
let result = tokio::task::spawn_blocking(move || {
write_repo(|db| {
db.with_connection(|conn| qbz_mixtape::repo::delete_collection(conn, &write_id))
.map_err(|e| e.to_string())
})
})
.await
.unwrap_or_else(|e| Err(format!("delete task panicked: {e}")));
match result {
Ok(()) => {
close();
crate::myqbz_prefs_qt::remove_view_prefs(&id);
crate::nav_qt::back();
crate::myqbz_qt::reload_grids();
}
Err(e) => {
log::warn!("[qbz-qt] myqbz_edit delete failed: {e}");
set_busy(false);
crate::toast_qt::error(qbz_i18n::t("Failed to delete"));
}
}
});
}
/// Bulk-remove the current select-mode selection.
pub(crate) fn remove_selected() {
remove_positions(crate::myqbz_detail_qt::selected_positions());
}
/// Per-row "Remove from collection" — routes through the SAME bulk remover with
/// a 1-element vec (spec 02 §4.1), so the compaction rule, the toast and the
/// reload are shared and cannot drift.
pub(crate) fn remove_item(position: i32) {
remove_positions(vec![position]);
}
/// Remove `positions` from the open collection. They are removed
/// HIGHEST-FIRST so each `repo::remove_item`'s position compaction never shifts
/// a position still to be deleted (T11). Per-item errors are logged and the
/// batch continues; only a hard DB-unavailable surfaces.
fn remove_positions(mut positions: Vec<i32>) {
let id = crate::myqbz_detail_qt::current_id();
if id.is_empty() || positions.is_empty() {
return;
}
positions.sort_unstable_by(|a, b| b.cmp(a));
let count = positions.len();
crate::spawn(async move {
let write_id = id.clone();
let result = tokio::task::spawn_blocking(move || {
write_repo(|db| {
db.with_connection(|conn| {
for pos in &positions {
if let Err(e) = qbz_mixtape::repo::remove_item(conn, &write_id, *pos) {
log::warn!(
"[qbz-qt] myqbz_edit remove_item({write_id}, {pos}) failed: {e}"
);
}
}
});
Ok(())
})
})
.await
.unwrap_or_else(|e| Err(format!("bulk-remove task panicked: {e}")));
match result {
Ok(()) => {
crate::myqbz_detail_qt::clear_selection();
crate::toast_qt::info(qbz_i18n::tf(
"Removed {} item",
"Removed {} items",
count as i64,
&[&count.to_string()],
));
reload(id);
}
Err(e) => {
log::warn!("[qbz-qt] myqbz_edit bulk-remove failed: {e}");
crate::toast_qt::error(qbz_i18n::t("Failed to remove items"));
}
}
});
}
#[cfg(test)]
mod tests {
/// The rename cap is 80 Unicode SCALARS, not bytes — a CJK or accented
/// name must not be cut mid-character.
#[test]
fn rename_caps_at_eighty_scalars() {
let raw = " ".to_string() + &"あ".repeat(200) + " ";
let capped: String = raw.trim().chars().take(80).collect();
assert_eq!(capped.chars().count(), 80);
}
/// Descending order is what makes the repo's position compaction harmless.
#[test]
fn bulk_remove_positions_go_highest_first() {
let mut positions = vec![0, 5, 2, 9];
positions.sort_unstable_by(|a, b| b.cmp(a));
assert_eq!(positions, vec![9, 5, 2, 0]);
}
}
+303
View File
@@ -0,0 +1,303 @@
//! My QBZ — DJ-mix "Random queue" sampler. The Qt port of
//! `crates/qbz/src/myqbz_mix.rs` (spec 02 §6.6), publishing
//! `QbzMyQbz.mixJson` (spec 02 §5.6).
//!
//! ## Data path
//!
//! - **open**: publish `{open:true, loading:true, …}` immediately, load the
//! collection on a blocking worker, resolve its items **IN ORDER**
//! (`play_mode` is IGNORED for DJ-mix — the sampler does its own
//! randomization), then run the DETERMINISTIC
//! `qbz_mixtape::shuffle::unique_track_count` for the slider max. The discrete
//! size set is [`build_size_options`]; the slider indexes into it.
//! - **shuffle**: re-resolve in order, then — in a SYNC scope that ends BEFORE
//! any `.await` — run `dedup_by_similarity` + `hybrid_sample`. The sampled
//! queue goes to `myqbz_play_qt::play_all_tracks` (replace + start at 0 +
//! queue-source stamp + best-effort `touch_play`).
//!
//! ## RNG confinement (load-bearing, spec 02 §7 T2)
//!
//! `rand::rng()` returns a `ThreadRng`, which is `!Send`. Holding it across an
//! `.await` makes the spawned future non-`Send` and FAILS TO COMPILE. So the
//! resolve `.await` completes FIRST into an owned `Vec<QueueTrack>`; the
//! dedup + sample then run inside a plain synchronous block that creates, uses
//! and DROPS the RNG entirely before the next `.await`.
//!
//! The sampler constants (`SIMILARITY_THRESHOLD` 0.80, `ALBUM_CAP_PCT` 0.30,
//! `ALBUM_CAP_MIN` 2) live in `qbz-mixtape` and are NOT duplicated here.
use cxx_qt_lib::QString;
use qbz_models::QueueTrack;
use serde::Serialize;
/// Below this unique-count the only option is a single "All (N)" entry.
const SMALL_THRESHOLD: i32 = 50;
/// Intermediate options step (50, 100, 150, …).
const STEP: i32 = 50;
// ---------------------------------------------------------------------------
// Document (spec 02 §5.6)
// ---------------------------------------------------------------------------
#[derive(Clone, Default, Serialize)]
pub struct MixDoc {
pub open: bool,
pub loading: bool,
#[serde(rename = "uniqueCount")]
pub unique_count: i32,
#[serde(rename = "sizeOptions")]
pub size_options: Vec<i32>,
#[serde(rename = "selectedIndex")]
pub selected_index: i32,
#[serde(rename = "selectedSize")]
pub selected_size: i32,
#[serde(rename = "selectedIsAll")]
pub selected_is_all: bool,
pub busy: bool,
}
static DOC: std::sync::Mutex<Option<MixDoc>> = std::sync::Mutex::new(None);
fn with_doc<R>(f: impl FnOnce(&mut MixDoc) -> R) -> R {
let mut guard = DOC.lock().unwrap();
let doc = guard.get_or_insert_with(MixDoc::default);
f(doc)
}
fn publish(doc: &MixDoc) {
let json = serde_json::to_string(doc).unwrap_or_else(|_| "{}".into());
crate::myqbz_bridge::ui(move |mut b| {
b.as_mut().set_mix_json(QString::from(json.as_str()));
});
}
fn publish_current() {
let doc = with_doc(|d| d.clone());
publish(&doc);
}
// ---------------------------------------------------------------------------
// Size options
// ---------------------------------------------------------------------------
/// The discrete size options for `unique_count`. The LAST entry is ALWAYS the
/// "All (N)" option, so `index == len - 1` ⇒ "All".
///
/// - `<= 0` ⇒ `[]` (the modal stays empty)
/// - `< 50` ⇒ `[unique_count]` (one "All (N)" entry)
/// - else ⇒ `[50,100,150,…]` for each `s < unique_count`, then a trailing
/// `unique_count`. The loop stops STRICTLY below, so a multiple of 50 does not
/// duplicate: `100` ⇒ `[50, 100]`, never `[50, 100, 100]`.
pub(crate) fn build_size_options(unique_count: i32) -> Vec<i32> {
if unique_count <= 0 {
return Vec::new();
}
if unique_count < SMALL_THRESHOLD {
return vec![unique_count];
}
let mut out = Vec::new();
let mut s = STEP;
while s < unique_count {
out.push(s);
s += STEP;
}
out.push(unique_count); // trailing "All (N)".
out
}
/// Set the slider index and derive the selected size + is-all flag. Clamps to
/// the valid range; an empty option set zeroes everything.
pub(crate) fn apply_index(index: i32) {
let doc = with_doc(|d| {
let len = d.size_options.len() as i32;
if len == 0 {
d.selected_index = 0;
d.selected_size = 0;
d.selected_is_all = false;
} else {
let idx = index.clamp(0, len - 1);
d.selected_index = idx;
d.selected_size = d.size_options.get(idx as usize).copied().unwrap_or(0);
// The trailing entry is always the "All (N)" option.
d.selected_is_all = idx == len - 1;
}
d.clone()
});
publish(&doc);
}
/// Push the resolved options for `unique_count`, defaulting the selection to
/// index 0 (= 50 for a large collection, or the single "All (N)" entry).
fn apply_options(unique_count: i32) {
with_doc(|d| {
d.unique_count = unique_count;
d.size_options = build_size_options(unique_count);
d.loading = false;
});
apply_index(0);
}
// ---------------------------------------------------------------------------
// open / close
// ---------------------------------------------------------------------------
/// Close the modal + clear its flags.
pub(crate) fn close() {
let doc = with_doc(|d| {
d.open = false;
d.busy = false;
d.loading = false;
d.clone()
});
publish(&doc);
}
fn close_with_error(msg: String) {
close();
crate::toast_qt::error(msg);
}
/// Drop the modal state on logout.
pub(crate) fn teardown() {
let doc = with_doc(|d| {
*d = MixDoc::default();
d.clone()
});
publish(&doc);
}
/// Open the DJ-mix modal for the collection on screen: show it in its
/// "computing…" state, resolve in order on a worker, count unique tracks
/// (deterministic, no RNG) and fill the slider. A resolve that yields nothing
/// closes the modal with an error toast.
pub(crate) fn open() {
let collection_id = crate::myqbz_detail_qt::current_id();
if collection_id.is_empty() {
return;
}
// Show the modal immediately in its loading state.
with_doc(|d| {
d.loading = true;
d.busy = false;
d.unique_count = 0;
d.size_options = Vec::new();
d.selected_index = 0;
d.selected_size = 0;
d.selected_is_all = false;
d.open = true;
});
publish_current();
let runtime = crate::app();
crate::spawn(async move {
let Some(collection) = crate::myqbz_play_qt::load_collection(&collection_id).await else {
close_with_error(qbz_i18n::t("Couldn't load this collection"));
return;
};
// Always InOrder for DJ-mix (force_shuffle = false): the sampler does
// its own randomization; the resolve only needs the full track pool.
let tracks = crate::myqbz_play_qt::resolve_collection(&runtime, &collection, false).await;
let unique = qbz_mixtape::shuffle::unique_track_count(&tracks) as i32;
if unique <= 0 {
close();
crate::toast_qt::error(qbz_i18n::t(
"This collection resolved to 0 playable tracks",
));
} else {
apply_options(unique);
}
});
}
// ---------------------------------------------------------------------------
// confirm / sample
// ---------------------------------------------------------------------------
/// Confirm: sample `sample_size` songs and replace-play the queue. The modal
/// closes BEFORE playback starts (1:1 with `handleConfirmMix`). When the sampled
/// count is below the requested one (the per-album cap can shrink the pool) an
/// info toast reports it.
pub(crate) fn confirm() {
let sample_size = with_doc(|d| d.selected_size);
shuffle(sample_size);
}
/// The confirm body, taking the size explicitly so a caller that already knows
/// it does not have to round-trip through the document.
pub(crate) fn shuffle(sample_size: i32) {
let collection_id = crate::myqbz_detail_qt::current_id();
if collection_id.is_empty() || sample_size <= 0 {
return;
}
// Disable the Shuffle button while in flight.
with_doc(|d| d.busy = true);
publish_current();
let runtime = crate::app();
crate::spawn(async move {
let Some(collection) = crate::myqbz_play_qt::load_collection(&collection_id).await else {
close_with_error(qbz_i18n::t("Couldn't load this collection"));
return;
};
// Resolve in order — this `.await` completes BEFORE the RNG exists.
let resolved = crate::myqbz_play_qt::resolve_collection(&runtime, &collection, false).await;
// ── RNG-confined sync scope (T2): create, use and DROP the !Send
// ThreadRng entirely here — it never crosses the `.await` below.
let requested = sample_size as usize;
let sampled: Vec<QueueTrack> = {
let mut rng = rand::rng();
let deduped = qbz_mixtape::shuffle::dedup_by_similarity(resolved, &mut rng);
qbz_mixtape::shuffle::hybrid_sample(deduped, requested, &mut rng)
};
// `rng` is dropped; from here on the future is `Send` again.
let actual = sampled.len();
// Close before playback starts.
close();
// Replace-play: set_queue + start at 0 + queue-source stamp +
// best-effort touch_play.
crate::myqbz_play_qt::play_all_tracks(&runtime, &collection_id, sampled).await;
// The per-album cap can shrink the pool below the request — surface it.
if actual > 0 && actual < requested {
crate::toast_qt::info(qbz_i18n::t_args(
"Playing {} of {}",
&[&actual.to_string(), &requested.to_string()],
));
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn size_options_empty_for_non_positive_counts() {
assert!(build_size_options(0).is_empty());
assert!(build_size_options(-5).is_empty());
}
#[test]
fn size_options_small_collection_is_one_all_entry() {
assert_eq!(build_size_options(1), vec![1]);
assert_eq!(build_size_options(49), vec![49]);
}
#[test]
fn size_options_multiple_of_fifty_does_not_duplicate() {
assert_eq!(build_size_options(50), vec![50]);
assert_eq!(build_size_options(100), vec![50, 100]);
assert_eq!(build_size_options(150), vec![50, 100, 150]);
}
#[test]
fn size_options_trailing_entry_is_always_the_total() {
let opts = build_size_options(137);
assert_eq!(opts, vec![50, 100, 137]);
assert_eq!(*opts.last().unwrap(), 137);
}
}
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more