diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 2673be0cf..36d8678e0 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -578,6 +578,7 @@ fn main() { "qml/controls/ScopePanel.qml", "qml/controls/QbzSegToggle.qml", "qml/controls/QbzSelect.qml", + "qml/controls/QbzSplitButton.qml", "qml/controls/QbzSlider.qml", "qml/controls/QbzTabBar.qml", "qml/controls/QbzTextArea.qml", diff --git a/crates/qbz-qt/qml/cards/AlbumCard.qml b/crates/qbz-qt/qml/cards/AlbumCard.qml index d12a391c5..7ed707cbc 100644 --- a/crates/qbz-qt/qml/cards/AlbumCard.qml +++ b/crates/qbz-qt/qml/cards/AlbumCard.qml @@ -198,6 +198,8 @@ Rectangle { // there is no host-painted tick left in the tree, and a new one is a // regression, not a shortcut. property bool selectMode: false + /// Park the select indicator bottom-right of the art instead of top-right. + property bool selectMarkBottom: false property bool selected: false signal selectToggled() @@ -551,7 +553,10 @@ Rectangle { Rectangle { visible: root.selectMode x: parent.width - width - 8 - y: 8 + // Top-right by default (the Library bulk mode); the artist + // page's album-section "Play selected" parks it bottom-right + // inside the art (`selectMarkBottom`). + y: root.selectMarkBottom ? parent.height - height - 8 : 8 width: 24 height: 24 radius: 12 diff --git a/crates/qbz-qt/qml/controls/QbzSplitButton.qml b/crates/qbz-qt/qml/controls/QbzSplitButton.qml new file mode 100644 index 000000000..01b0874c6 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzSplitButton.qml @@ -0,0 +1,170 @@ +// QbzSplitButton — a toolbar button that LOOKS like a select: the body runs +// the primary action, the chevron cell opens a menu of alternatives. +// +// +--------------+-+ +// | Play all |v| +// +--------------+-+ +// +// Sized and painted like `QbzSelect { sm: true }` so it sits flush beside one +// in a section header (30 tall, r6, elevated fill, no outline, 12px label, +// 14px chevron): the album-section "Play all / Play random / Play selected" +// control on the artist page is the first host. The menu is the shared +// controls/QbzContextMenu opened below-right of the whole control, the +// QbzSelect / sort-menu convention. +// +// `menuItems` is `[{ label, action, icon? }]`; a pick closes the menu and +// emits `picked(action)`. The body emits `clicked()` and never opens the +// menu — the two halves are separate hit zones with their own hover fill, +// and the 1px hairline between them is the split. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Rectangle { + id: root + + property string label: "" + property var menuItems: [] + property int menuWidth: 180 + property bool btnEnabled: true + signal clicked() + signal picked(string action) + + QbzTheme { id: theme } + + readonly property int chevronWidth: 24 + + height: 30 + radius: 6 + implicitWidth: bodyLabel.implicitWidth + 24 + root.chevronWidth + width: implicitWidth + // Same resting fill as QbzSelect sm (translucent under the dynamic + // background); hover is painted per zone below. + color: theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated + opacity: root.btnEnabled ? 1.0 : 0.4 + + // --- Body: the primary action ------------------------------------------ + Rectangle { + id: body + x: 0 + y: 0 + width: parent.width - root.chevronWidth + height: parent.height + radius: 6 + color: bodyArea.containsMouse && root.btnEnabled ? theme.surfaceHover : "transparent" + // Square the inner corners so the hover fill meets the hairline. + Rectangle { + x: parent.width - 6 + y: 0 + width: 6 + height: parent.height + color: parent.color + } + Text { + id: bodyLabel + anchors.centerIn: parent + text: root.label + color: theme.textPrimary + font.pixelSize: 12 + verticalAlignment: Text.AlignVCenter + } + MouseArea { + id: bodyArea + anchors.fill: parent + enabled: root.btnEnabled + hoverEnabled: true + cursorShape: root.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: root.clicked() + } + } + + // --- The split ---------------------------------------------------------- + Rectangle { + x: parent.width - root.chevronWidth + y: 7 + width: 1 + height: parent.height - 14 + color: theme.borderSubtle + } + + // --- Chevron: the menu -------------------------------------------------- + Rectangle { + id: chevron + x: parent.width - root.chevronWidth + y: 0 + width: root.chevronWidth + height: parent.height + radius: 6 + color: chevronArea.containsMouse && root.btnEnabled ? theme.surfaceHover : "transparent" + Rectangle { + x: 0 + y: 0 + width: 6 + height: parent.height + color: parent.color + } + QbzIcon { + name: "chevron-down" + width: 14 + height: 14 + anchors.centerIn: parent + tintName: chevronArea.containsMouse ? "textPrimary" : "secondary" + } + MouseArea { + id: chevronArea + anchors.fill: parent + enabled: root.btnEnabled + hoverEnabled: true + cursorShape: root.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: menu.openBelowRight(root) + } + } + + QbzContextMenu { + id: menu + menuWidth: root.menuWidth + Repeater { + model: root.menuItems + delegate: Rectangle { + required property var modelData + width: parent ? parent.width : 0 + height: 33 + radius: 5 + color: itemArea.containsMouse ? theme.surfaceHover : "transparent" + Row { + anchors.fill: parent + anchors.leftMargin: 8 + spacing: 8 + QbzIcon { + visible: (modelData.icon || "") !== "" + name: modelData.icon || "" + width: visible ? 15 : 0 + height: 15 + anchors.verticalCenter: parent.verticalCenter + tintName: "secondary" + } + Text { + height: parent.height + width: parent.width - 23 + text: modelData.label + color: theme.textSecondary + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + MouseArea { + id: itemArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + menu.close() + root.picked(modelData.action) + } + } + } + } + } +} diff --git a/crates/qbz-qt/qml/views/ArtistView.qml b/crates/qbz-qt/qml/views/ArtistView.qml index a61183768..77a033bec 100644 --- a/crates/qbz-qt/qml/views/ArtistView.qml +++ b/crates/qbz-qt/qml/views/ArtistView.qml @@ -327,6 +327,92 @@ Rectangle { for (var i = 0; i < root.libTracks.length; i++) ids.push(String(root.libTracks[i].id)) return ids } + /// The library Albums section's sort — the release buckets' five keys, + /// seeded from the document (`artist.librarySort`, artist_prefs "library") + /// and persisted through the same invokable the buckets use. Sorted here, + /// not in Rust: the rows already live in QML (`sortReleaseCards` reads + /// `title` / `year`, which a FeedItem carries). + property string libSort: "default" + readonly property var libAlbumsSorted: root.sortReleaseCards(root.libAlbums, root.libSort) + function setLibSort(key) { + root.libSort = key + QbzArtist.setSectionSort("library", key) + } + + // ---- Album-section play (the split button) ------------------------------- + // Every album section — each catalog bucket and the library Albums grid — + // carries a QbzSplitButton right of its sort: the body plays the whole + // section in its SORT order as one queue (`QbzPlayer.playAlbums`), the + // chevron offers Play random (ONE album of the section, replacing the + // queue) and Play selected, which arms a per-section select mode: the + // cards grow a check bottom-right inside the art, the button reads + // "Play selected (n)" and plays the checked albums in sort order, and the + // menu swaps that row for Cancel selection. One section is armed at a + // time (`albumSelectKey` is the bucket's releaseType or "library"). + // No queue options (next / later / queue) on purpose. + property string albumSelectKey: "" + property var albumSelected: ({}) + readonly property int albumSelectedCount: Object.keys(root.albumSelected).length + function albumSelectToggle(id) { + var m = Object.assign({}, root.albumSelected) + if (m[id]) delete m[id] + else m[id] = true + root.albumSelected = m + } + function albumSelectClear() { + root.albumSelectKey = "" + root.albumSelected = ({}) + } + function albumSectionLabel(key) { + return root.albumSelectKey === key + ? QbzSession.tr("Play selected", QbzSession.trRev) + " (" + root.albumSelectedCount + ")" + : QbzSession.tr("Play all", QbzSession.trRev) + } + function albumSectionMenu(key) { + var m = [ + { "label": QbzSession.tr("Play all", QbzSession.trRev), "icon": "play-fill", "action": "play-all" }, + { "label": QbzSession.tr("Play random", QbzSession.trRev), "icon": "shuffle", "action": "play-random" } + ] + if (root.albumSelectKey === key) + m.push({ "label": QbzSession.tr("Cancel selection", QbzSession.trRev), "icon": "x", "action": "cancel-selection" }) + else + m.push({ "label": QbzSession.tr("Play selected", QbzSession.trRev), "icon": "square-check-big", "action": "play-selected" }) + return m + } + function albumSectionIds(cards) { + var ids = [] + for (var i = 0; i < cards.length; i++) ids.push(String(cards[i].id)) + return ids + } + /// `cards` is the section's list in its CURRENT sort order. + function albumSectionAction(key, cards, action) { + var ids = root.albumSectionIds(cards) + if (action === "play-all") { + root.albumSelectClear() + if (ids.length > 0) QbzPlayer.playAlbums(JSON.stringify(ids)) + } else if (action === "play-random") { + root.albumSelectClear() + if (ids.length > 0) QbzPlayer.playAlbum(ids[Math.floor(Math.random() * ids.length)]) + } else if (action === "play-selected") { + root.albumSelectKey = key + root.albumSelected = ({}) + } else if (action === "cancel-selection") { + root.albumSelectClear() + } + } + /// The button body: Play all, or — armed — the checked albums in sort + /// order, then disarm. + function albumSectionPrimary(key, cards) { + if (root.albumSelectKey !== key) { + root.albumSectionAction(key, cards, "play-all") + return + } + var ids = [] + for (var i = 0; i < cards.length; i++) + if (root.albumSelected[cards[i].id] === true) ids.push(String(cards[i].id)) + root.albumSelectClear() + if (ids.length > 0) QbzPlayer.playAlbums(JSON.stringify(ids)) + } // ---- Header atmosphere (ArtistPageView.slint:120-147) ---------------- // Same three-line rule as AlbumView (the .slint says "same rule as @@ -882,6 +968,7 @@ Rectangle { // switch. The strip re-anchors on the first section the new mode // draws (catalog keeps its Popular Tracks default). setMultiSelect(false) + albumSelectClear() // The first SECTION, not "About" (back-to-top) when a section exists. var first = "" for (var i = 0; i < root.jumpTabs.length; i++) { @@ -979,6 +1066,8 @@ Rectangle { libTracksExpanded = false libShowPurchases = false libShowFavorites = false + libSort = artist.librarySort || "default" + albumSelectClear() // Slint opens a fresh artist on Network, or on Magazine when // MusicBrainz is off (an empty Network tab is worse than none). netTab = (artist.network && artist.network.mbAvailable) ? "network" : "magazine" @@ -1950,7 +2039,7 @@ Rectangle { width: parent.width spacing: 12 Text { - width: parent.width - seeAll.width - sortBtn.width - 24 + width: parent.width - seeAll.width - sortBtn.width - playBtn.width - 36 anchors.verticalCenter: parent.verticalCenter text: section.title color: theme.textPrimary @@ -2062,6 +2151,21 @@ Rectangle { : i === 3 ? "title-asc" : i === 4 ? "title-desc" : "default") } } + // Play all / Play random / Play selected over THIS bucket, in + // its sort order (`section.cards` is already sorted — Rust sorts + // the bucket and the overlay merge re-sorts). + QbzSplitButton { + id: playBtn + anchors.verticalCenter: parent.verticalCenter + label: root.albumSectionLabel(relSection.section.releaseType || "") + menuItems: root.albumSectionMenu(relSection.section.releaseType || "") + onClicked: root.albumSectionPrimary(relSection.section.releaseType || "", + relSection.section.cards || []) + onPicked: function (a) { + root.albumSectionAction(relSection.section.releaseType || "", + relSection.section.cards || [], a) + } + } } Grid { @@ -2100,6 +2204,11 @@ Rectangle { // artist_qt::map_release stamps this the same way it // stamps the pin; false inverted the first click. isFavorite: modelData.isFavorite === true + // "Play selected" over this bucket (the split button). + selectMode: root.albumSelectKey === (relSection.section.releaseType || "") + selected: root.albumSelected[modelData.id] === true + selectMarkBottom: true + onSelectToggled: root.albumSelectToggle(modelData.id) // ---- smooth append (owner, 2026-08-02) --------------- // "que la aparicion de lo que se cargue, sea smooth" — @@ -3053,13 +3162,51 @@ Rectangle { // --- Albums --------------------------------------------- Item { visible: root.libAlbums.length > 0 && root.libTracks.length > 0; width: 1; height: 24 } - Text { + // Header: title, the release buckets' sort select, and the + // split play button — the catalog section header's cluster + // minus "See discography" (there is nothing to page here). + Row { property string anchorId: "albums" visible: root.libAlbums.length > 0 - text: QbzSession.tr("Albums", QbzSession.trRev) - color: theme.textPrimary - font.pixelSize: theme.fontHeading - font.weight: theme.weightSemibold + width: parent.width + spacing: 12 + Text { + width: parent.width - libSortBtn.width - libPlayBtn.width - 24 + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Albums", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + QbzSelect { + id: libSortBtn + anchors.verticalCenter: parent.verticalCenter + sm: true + menuWidth: 150 + options: [QbzSession.tr("Default", QbzSession.trRev), + QbzSession.tr("Newest", QbzSession.trRev), + QbzSession.tr("Oldest", QbzSession.trRev), + QbzSession.tr("A–Z", QbzSession.trRev), + QbzSession.tr("Z–A", QbzSession.trRev)] + currentIndex: { + var s = root.libSort || "default" + return s === "newest" ? 1 : s === "oldest" ? 2 + : s === "title-asc" ? 3 : s === "title-desc" ? 4 : 0 + } + onSelected: function (i) { + root.setLibSort(i === 1 ? "newest" : i === 2 ? "oldest" + : i === 3 ? "title-asc" : i === 4 ? "title-desc" : "default") + } + } + QbzSplitButton { + id: libPlayBtn + anchors.verticalCenter: parent.verticalCenter + label: root.albumSectionLabel("library") + menuItems: root.albumSectionMenu("library") + onClicked: root.albumSectionPrimary("library", root.libAlbumsSorted) + onPicked: function (a) { root.albumSectionAction("library", root.libAlbumsSorted, a) } + } } Item { visible: root.libAlbums.length > 0; width: 1; height: 10 } Grid { @@ -3069,8 +3216,14 @@ Rectangle { columnSpacing: 24 rowSpacing: 24 Repeater { - model: root.libAlbums + model: root.libAlbumsSorted delegate: AlbumCard { + // "Play selected" over this section (the split + // button), check bottom-right inside the art. + selectMode: root.albumSelectKey === "library" + selected: root.albumSelected[modelData.id] === true + selectMarkBottom: true + onSelectToggled: root.albumSelectToggle(modelData.id) albumId: modelData.id title: modelData.title artist: modelData.artist diff --git a/crates/qbz-qt/src/artist_qt.rs b/crates/qbz-qt/src/artist_qt.rs index 4f539044d..06de9cdbb 100644 --- a/crates/qbz-qt/src/artist_qt.rs +++ b/crates/qbz-qt/src/artist_qt.rs @@ -368,6 +368,13 @@ pub struct ArtistViewData { /// repaint the tab through `publish_patch`, the page's one transport. #[serde(rename = "libraryItems")] pub library_items: Vec, + /// Sort of the "In library" Albums section — the same five keys the + /// release buckets use, persisted by `artist_prefs` under the pseudo + /// release type `library` (`QbzArtist.setSectionSort("library", …)`). + /// The rows are sorted in QML (they live there already); only the seed + /// travels here. + #[serde(rename = "librarySort")] + pub library_sort: String, #[serde(rename = "topTracks")] pub top_tracks: Vec, #[serde(rename = "appearsOn")] @@ -880,6 +887,7 @@ fn map_artist(page: PageArtistResponse) -> ArtistViewData { is_blacklisted: crate::artist_blacklist::is_blacklisted(page.id), library_count, library_items, + library_sort: crate::artist_prefs::get_sort("library"), top_tracks, appears_on, last_release, diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs index fe1050f62..6431695cf 100644 --- a/crates/qbz-qt/src/playback_qt.rs +++ b/crates/qbz-qt/src/playback_qt.rs @@ -2237,6 +2237,43 @@ pub async fn play_album( play_album_from(runtime, album_id, 0).await } +/// An album SECTION's "Play all" / "Play selected" (artist page split +/// button): every album's tracks, in the order given, as ONE queue that +/// replaces the current one — track 1 of the first album starts. Each album +/// resolves through `fetch_album_queue` like a single-album play, so a +/// purchase preference or a withdrawn row is treated identically; an album +/// that fails to resolve is logged and skipped rather than sinking the whole +/// section. Local / Plex group keys are not a catalog section and are +/// skipped. +pub async fn play_albums( + runtime: &Arc>, + album_ids: &[String], +) -> Result<(), String> { + let mut queue: Vec = Vec::new(); + for album_id in album_ids { + if is_local_album(album_id) { + log::info!("[qbz-qt] play_albums: skipping local group key {album_id}"); + continue; + } + match fetch_album_queue(runtime, album_id).await { + Ok(mut tracks) => queue.append(&mut tracks), + Err(e) => log::warn!("[qbz-qt] play_albums: {album_id} skipped: {e}"), + } + } + if queue.is_empty() { + return Err(format!( + "none of the {} album(s) resolved to playable tracks", + album_ids.len() + )); + } + log::info!( + "[qbz-qt] play_albums: queueing {} track(s) from {} album(s)", + queue.len(), + album_ids.len() + ); + play_track_list(runtime, queue, 0, false).await +} + /// Play an album starting at track `start_index` (AlbumView row play — /// Slint's play_album_from semantics: the queue keeps the whole album, /// the cursor starts at the clicked track). diff --git a/crates/qbz-qt/src/player_bridge.rs b/crates/qbz-qt/src/player_bridge.rs index 21d951b57..7dfb031ab 100644 --- a/crates/qbz-qt/src/player_bridge.rs +++ b/crates/qbz-qt/src/player_bridge.rs @@ -200,6 +200,11 @@ pub mod qbz_player { /// AlbumView header Shuffle. #[qinvokable] fn play_album_shuffled(self: Pin<&mut QbzPlayer>, album_id: QString); + /// Artist page album-section split button — a JSON array of album + /// ids in the section's sort order, queued as one list. + /// `#[auto_cxx_name]` makes the QML name `QbzPlayer.playAlbums`. + #[qinvokable] + fn play_albums(self: Pin<&mut QbzPlayer>, ids_json: QString); /// AlbumView row play: the album starting AT this track. #[qinvokable] fn play_album_from(self: Pin<&mut QbzPlayer>, album_id: QString, track_id: QString); @@ -477,6 +482,10 @@ impl qbz_player::QbzPlayer { crate::play_album(album_id.to_string()); } + pub fn play_albums(self: Pin<&mut Self>, ids_json: QString) { + crate::play_albums(ids_json.to_string()); + } + pub fn play_album_shuffled(self: Pin<&mut Self>, album_id: QString) { crate::play_album_shuffled(album_id.to_string()); }