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
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 || ""))
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}]))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
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.
|
||||
// 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).
|
||||
// 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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
{
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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", "", " "));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||