diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index cd8ee6e71..101eaf717 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -52,6 +52,8 @@ fn main() { "qml/HomeView.qml", "qml/LibraryView.qml", "qml/SettingsView.qml", + "qml/SearchView.qml", + "qml/Cortinilla.qml", "qml/QbzTheme.qml", "qml/QbzIcon.qml", "qml/QbzScrollBar.qml", diff --git a/crates/qbz-qt/qml/AppShell.qml b/crates/qbz-qt/qml/AppShell.qml index 0de98ee1b..669cfe6ff 100644 --- a/crates/qbz-qt/qml/AppShell.qml +++ b/crates/qbz-qt/qml/AppShell.qml @@ -145,7 +145,15 @@ Rectangle { : QbzBridge.currentView === "library" ? "LibraryView.qml" : QbzBridge.currentView === "album" ? "AlbumView.qml" : QbzBridge.currentView === "artist" ? "ArtistView.qml" - : QbzBridge.currentView === "settings" ? "SettingsView.qml" : "" + : QbzBridge.currentView === "settings" ? "SettingsView.qml" + : QbzBridge.currentView === "search" ? "SearchView.qml" : "" } } + + // Search cortinilla (phase 15): the live-search dropdown overlay, LAST + // child so it renders above every surface (Cortinilla.slint's mount). + Cortinilla { + anchors.fill: parent + headerBar: header + } } diff --git a/crates/qbz-qt/qml/Cortinilla.qml b/crates/qbz-qt/qml/Cortinilla.qml new file mode 100644 index 000000000..1362c12b1 --- /dev/null +++ b/crates/qbz-qt/qml/Cortinilla.qml @@ -0,0 +1,356 @@ +// Search "cortinilla" — the live as-you-type dropdown under the header +// search box (search/Cortinilla.slint, phase 15). A plain Item overlay +// mounted as a LAST child of AppShell (NOT a Popup — a Popup grabs the +// pointer and would kill typing in the header field, the exact reason the +// Slint uses a plain Rectangle too). +// +// Payload: QbzBridge.cortinillaJson (search_qt.rs CortinillaData: query / +// top / sections with controller flat indices). Keyboard selection rides +// selectedIndex + cortinillaScrollY (content-space top-y of the selected +// row — scrolled into view here). Row activation bubbles to the +// QbzBridge.cortinilla* invokables; the field clear goes through +// root.header.clearSearch(). + +import QtQuick +import QtQuick.Controls +import com.blitzfc.qbz + +Item { + id: root + anchors.fill: parent + visible: QbzBridge.cortinillaOpen + + // The host HeaderBar (for clearSearch on row activation). + property var headerBar: null + + QbzTheme { id: theme } + + readonly property var doc: parseDoc() + function parseDoc() { + try { + return JSON.parse(QbzBridge.cortinillaJson) + } catch (e) { + return ({}) + } + } + readonly property var sections: doc.sections || [] + readonly property bool hasTop: doc.top !== undefined && doc.top !== null && (doc.top.id || "") !== "" + readonly property bool empty: !hasTop && sections.length === 0 && !QbzBridge.cortinillaLoading + + // The centered header search box width (HeaderBar's responsive rule) — + // the click-through gaps below leave it free. + readonly property int searchBoxWidth: root.width < 960 ? 179 : 256 + readonly property int panelWidth: 320 + // Cap so the panel NEVER covers the now-playing bar (Cortinilla.slint). + readonly property int scrollCap: root.height - 42 - 6 - 42 - 16 - 12 + + // --- Idle auto-close (4.5s without hover/activity, Cortinilla.slint) -- + property bool panelHovered: false + Timer { + id: idleClose + interval: 4500 + repeat: false + running: root.visible && !root.panelHovered + onTriggered: QbzBridge.cortinillaDismiss() + } + Connections { + target: QbzBridge + // Activity restarts the countdown (keystroke or arrow move). + function onCortinillaJsonChanged() { if (root.visible && !root.panelHovered) idleClose.restart() } + function onSelectedIndexChanged() { if (root.visible && !root.panelHovered) idleClose.restart() } + // A page change dismisses (AppShell's tracked-view close hook). + function onCurrentViewChanged() { QbzBridge.cortinillaDismiss() } + // Keyboard scroll-into-view: the controller publishes the selected + // row's content-space top-y; nudge the Flickable so it is visible. + function onCortinillaScrollYChanged() { + var y = QbzBridge.cortinillaScrollY + if (y < bodyFlick.contentY) { + bodyFlick.contentY = Math.max(0, y) + } else if (y + 56 > bodyFlick.contentY + bodyFlick.height) { + bodyFlick.contentY = y + 56 - bodyFlick.height + } + } + } + + // --- Click-outside scrims (any click outside the panel dismisses) ---- + // 1) everything BELOW the header. + MouseArea { + visible: root.visible + x: 0 + y: 42 + width: root.width + height: root.height - 42 + onClicked: QbzBridge.cortinillaDismiss() + } + // 2) header strip LEFT of the search box. + MouseArea { + visible: root.visible + x: 0 + y: 0 + width: (root.width - root.searchBoxWidth) / 2 + height: 42 + onClicked: QbzBridge.cortinillaDismiss() + } + // 3) header strip RIGHT of the search box. + MouseArea { + visible: root.visible + x: (root.width + root.searchBoxWidth) / 2 + y: 0 + width: root.width - x + height: 42 + onClicked: QbzBridge.cortinillaDismiss() + } + + // --- The panel ---------------------------------------------------------- + Rectangle { + id: panel + x: (root.width - root.panelWidth) / 2 + y: 42 + 6 + width: root.panelWidth + height: Math.min(bodyColumn.height + 12, root.scrollCap + 12) + radius: theme.radiusSm + color: theme.surfaceMain + border.width: 1 + border.color: theme.borderMuted + clip: true + + MouseArea { + // Hover surface for the idle-close pause; clicks stay inside. + anchors.fill: parent + hoverEnabled: true + onContainsMouseChanged: root.panelHovered = containsMouse + } + + Flickable { + id: bodyFlick + anchors.fill: parent + anchors.topMargin: 6 + anchors.bottomMargin: 6 + contentWidth: width + contentHeight: bodyColumn.height + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + id: bodyColumn + width: bodyFlick.width + + // --- Loading skeleton (no cached instant-paint — one clean + // apply, Cortinilla.slint) ------------------------------- + Column { + visible: QbzBridge.cortinillaLoading + width: parent.width + topPadding: 4 + property bool pulse: false + Timer { + interval: 700 + running: parent.visible + repeat: true + onTriggered: parent.pulse = !parent.pulse + } + Repeater { + model: 5 + delegate: Item { + width: bodyColumn.width + height: 56 + Rectangle { + x: 10 + anchors.verticalCenter: parent.verticalCenter + width: 40 + height: 40 + radius: 4 + color: theme.surfaceElevated + opacity: parent.parent.pulse ? 0.48 : 0.16 + Behavior on opacity { NumberAnimation { duration: 650; easing.type: Easing.InOutQuad } } + } + Column { + x: 62 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + Rectangle { + width: 150 + height: 11 + radius: 3 + color: theme.surfaceElevated + opacity: parent.parent.parent.pulse ? 0.48 : 0.16 + Behavior on opacity { NumberAnimation { duration: 650; easing.type: Easing.InOutQuad } } + } + Rectangle { + width: 96 + height: 9 + radius: 3 + color: theme.surfaceElevated + opacity: parent.parent.parent.pulse ? 0.48 : 0.16 + Behavior on opacity { NumberAnimation { duration: 650; easing.type: Easing.InOutQuad } } + } + } + } + } + } + + // --- Loaded content ---------------------------------------- + Column { + visible: !QbzBridge.cortinillaLoading + width: parent.width + + // No results. + Item { + visible: root.empty + width: parent.width + height: 28 + Text { + x: 14 + anchors.verticalCenter: parent.verticalCenter + text: QbzBridge.tr("No results for") + " “" + (root.doc.query || "") + "”" + color: theme.textMuted + font.pixelSize: 12 + } + } + + // Top result (flat-index 0). + Column { + visible: root.hasTop + width: parent.width + leftPadding: 6 + rightPadding: 6 + topPadding: 4 + Text { + x: 10 + height: 22 + text: QbzBridge.tr("Top result") + color: theme.textMuted + font.pixelSize: 11 + font.weight: theme.weightSemibold + verticalAlignment: Text.AlignVCenter + } + CortRow { row: root.hasTop ? root.doc.top : ({}) } + } + + // Sections. + Repeater { + model: root.sections + delegate: Column { + required property var modelData + width: bodyColumn.width + leftPadding: 6 + rightPadding: 6 + topPadding: 4 + + Item { + width: parent.width + height: 24 + Text { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: modelData.title + color: theme.textMuted + font.pixelSize: 11 + font.weight: theme.weightSemibold + } + Text { + visible: modelData.hasMore === true + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: QbzBridge.tr("View more") + color: vmArea.containsMouse ? theme.textPrimary : theme.accent + font.pixelSize: 11 + font.weight: theme.weightMedium + MouseArea { + id: vmArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (root.headerBar) root.headerBar.clearSearch() + QbzBridge.cortinillaViewMore(modelData.kind) + } + } + } + } + Repeater { + model: modelData.rows + delegate: CortRow { row: modelData } + } + } + } + } + } + } + + QbzScrollBar { + target: bodyFlick + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + } + } + + // --- One result row (CortinillaResultRow) ------------------------------ + component CortRow: Rectangle { + property var row: ({}) + width: parent ? parent.width : 0 + height: 56 + radius: theme.radiusSm + readonly property bool active: QbzBridge.selectedIndex === (row.flatIndex ?? -2) + color: (active || rowArea.containsMouse) ? theme.surfaceHover : "transparent" + + // Accent bar on the keyboard-active row (distinct from plain hover). + Rectangle { + visible: parent.active + x: 0 + y: 4 + width: 3 + height: parent.height - 8 + radius: 2 + color: theme.accent + } + Rectangle { + x: 10 + anchors.verticalCenter: parent.verticalCenter + width: 40 + height: 40 + // Artists read better as a circle; everything else is a tile. + radius: row.kind === "artist" ? 20 : 4 + color: theme.surfaceElevated + clip: true + RoundedImage { + anchors.fill: parent + source: row.artPath || "" + radius: row.kind === "artist" ? 20 : 4 + } + } + Column { + x: 62 + width: parent.width - 62 - 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + width: parent.width + text: row.title || "" + color: theme.textPrimary + font.pixelSize: 14 + font.weight: theme.weightMedium + elide: Text.ElideRight + } + Text { + width: parent.width + text: row.subtitle || "" + color: theme.textMuted + font.pixelSize: 12 + elide: Text.ElideRight + } + } + MouseArea { + id: rowArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (root.headerBar) root.headerBar.clearSearch() + QbzBridge.cortinillaRowClicked(row.flatIndex) + } + } + } +} diff --git a/crates/qbz-qt/qml/HeaderBar.qml b/crates/qbz-qt/qml/HeaderBar.qml index ed10f2b24..11001ccfa 100644 --- a/crates/qbz-qt/qml/HeaderBar.qml +++ b/crates/qbz-qt/qml/HeaderBar.qml @@ -193,17 +193,24 @@ Rectangle { } } - // --- Search (absolutely centered; VISUAL replica only) --------------- - // POC-NOTE: the field is inert — the live-search cortinilla is out of - // scope; metrics/colors/placeholder match the Slint field. + // --- Search (absolutely centered; LIVE — phase 15) --------------------- + // The Slint HeaderBar search-scope: typing drives the cortinilla (220ms + // debounce, >= 2 chars), arrows move the keyboard selection, Enter runs + // the row / full search, Esc dismisses. The × clears + closes. + function clearSearch() { + searchInput.text = "" + QbzBridge.cortinillaDismiss() + } + Rectangle { + id: searchBox x: (root.width - width) / 2 y: (root.height - height) / 2 width: root.width < 960 ? 179 : 256 height: 32 radius: 6 border.width: 1 - border.color: theme.borderSubtle + border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle color: theme.surfaceElevated QbzIcon { @@ -214,14 +221,122 @@ Rectangle { anchors.verticalCenter: parent.verticalCenter tintName: "muted" } + TextInput { + id: searchInput + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 30 + anchors.rightMargin: 8 + height: parent.height + color: theme.textPrimary + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + horizontalAlignment: text === "" && !activeFocus ? Text.AlignHCenter : Text.AlignLeft + clip: true + onTextEdited: { + if (text.trim().length < 2) { + liveDebounce.stop() + QbzBridge.cortinillaDismiss() + } else { + liveDebounce.restart() + } + } + + // 220ms live debounce (CORTINILLA_DEBOUNCE — one load per pause, + // not one per keystroke). + Timer { + id: liveDebounce + interval: 220 + repeat: false + onTriggered: QbzBridge.searchLive(searchInput.text) + } + + // The Enter rule (HeaderBar.slint on-enter): cortinilla open + + // a keyboard selection -> activate the row; open + none -> full + // search; closed -> plain submit (also Search > All). + Keys.onPressed: function (event) { + if (event.key === Qt.Key_Down) { + if (QbzBridge.cortinillaOpen) { + QbzBridge.cortinillaMoveSelection(1) + event.accepted = true + } + } else if (event.key === Qt.Key_Up) { + if (QbzBridge.cortinillaOpen) { + QbzBridge.cortinillaMoveSelection(-1) + event.accepted = true + } + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + if (QbzBridge.cortinillaOpen) { + if (QbzBridge.selectedIndex >= 0) { + root.clearSearch() + QbzBridge.cortinillaRowClicked(QbzBridge.selectedIndex) + } else { + root.clearSearch() + QbzBridge.cortinillaSearchAll() + } + } else { + // Capture BEFORE clearing (clearSearch wipes the input). + var q = searchInput.text + root.clearSearch() + QbzBridge.searchSubmit(q) + } + event.accepted = true + } else if (event.key === Qt.Key_Escape) { + if (QbzBridge.cortinillaOpen) { + QbzBridge.cortinillaDismiss() + event.accepted = true + } + } + } + } + // Placeholder (centered when empty + unfocused, left once typing). Text { + visible: searchInput.text === "" && !searchInput.activeFocus anchors.fill: parent + anchors.leftMargin: 30 text: QbzBridge.tr("Search") color: theme.textMuted font.pixelSize: 13 verticalAlignment: Text.AlignVCenter horizontalAlignment: Text.AlignHCenter } + // Right-edge affordances: the Enter hint while the cortinilla is + // open (Slint: it lives in the box, opposite the magnifier), else + // the × clear. + Text { + visible: QbzBridge.cortinillaOpen + anchors.right: parent.right + anchors.rightMargin: 10 + height: parent.height + text: "⏎ " + QbzBridge.tr("Enter") + color: theme.textMuted + font.pixelSize: 12 + verticalAlignment: Text.AlignVCenter + } + Rectangle { + visible: !QbzBridge.cortinillaOpen && searchInput.text !== "" + anchors.right: parent.right + anchors.rightMargin: 5 + width: 22 + height: 22 + anchors.verticalCenter: parent.verticalCenter + radius: 11 + color: clearArea.containsMouse ? theme.surfaceHover : "transparent" + QbzIcon { + name: "x" + width: 12 + height: 12 + anchors.centerIn: parent + tintName: clearArea.containsMouse ? "primary" : "muted" + } + MouseArea { + id: clearArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clearSearch() + } + } } // --- Right controls: status badge + app menu -------------------------- @@ -544,6 +659,12 @@ Rectangle { } } + AppMenuItem { + name: "search" + label: QbzBridge.tr("Intelligent search") + checkedItem: QbzBridge.intelligentSearch + onClicked: QbzBridge.toggleIntelligentSearch() + } AppMenuItem { name: "layout-grid" label: QbzBridge.tr("Ambient background") diff --git a/crates/qbz-qt/qml/Main.qml b/crates/qbz-qt/qml/Main.qml index a53f92656..f807bfcdf 100644 --- a/crates/qbz-qt/qml/Main.qml +++ b/crates/qbz-qt/qml/Main.qml @@ -42,6 +42,7 @@ ApplicationWindow { + Loader { id: screenLoader anchors.fill: parent diff --git a/crates/qbz-qt/qml/SearchView.qml b/crates/qbz-qt/qml/SearchView.qml new file mode 100644 index 000000000..e6d555208 --- /dev/null +++ b/crates/qbz-qt/qml/SearchView.qml @@ -0,0 +1,966 @@ +// Search results view — the QML port of search/SearchResultsView.slint +// (phase 15). One JSON document (QbzBridge.searchJson, search_qt.rs +// SearchPageDoc: query/tab/loading/filterIndex + the four category lists, +// totals, the most-popular hero, the carousel-only artists list). +// +// Layout (1:1): 56px toolbar ("Search" title + the searchType filter +// radios on the right), the five-tab strip (All / Albums / Tracks / +// Artists / Playlists), the scrollable body: All = Most-popular hero + +// Artists carousel + Albums carousel + Tracks preview (6) + Playlists +// carousel; per-type tabs = full grid/list + "Load more (n / total)". +// +// POC-NOTEs: +// - The artist cards here replicate ArtistCard/ArtistGridCard inline for +// the third time (HomeView/LibraryView carry their own copies); sharing +// them is a follow-up refactor, not this phase. +// - Card click actions: albums/artists navigate; tracks play; playlists +// are inert (no playlist view in the POC). +// - The Slint's windowed grid virtualization is not replicated (page size +// is 20 — the whole set mounts). + +import QtQuick +import QtQuick.Controls +import com.blitzfc.qbz + +Rectangle { + id: root + color: ambientOn ? "transparent" : theme.surfaceMain + readonly property bool ambientOn: QbzBridge.ambientMode > 0 && QbzBridge.npHasTrack + radius: 12 + + QbzTheme { id: theme } + + readonly property var doc: parseDoc() + function parseDoc() { + try { + return JSON.parse(QbzBridge.searchJson) + } catch (e) { + return ({}) + } + } + readonly property var albums: doc.albums || [] + readonly property var tracks: doc.tracks || [] + readonly property var artists: doc.artists || [] + readonly property var artistsCarousel: doc.artistsCarousel || [] + readonly property var playlists: doc.playlists || [] + readonly property var mp: doc.mostPopular || ({}) + readonly property bool loading: doc.loading === true + readonly property int tab: doc.tab || 0 + readonly property int filterIndex: doc.filterIndex || 0 + readonly property bool hasResults: albums.length + tracks.length + artists.length + playlists.length > 0 + readonly property int previewCap: 6 + + // --- Small cards ------------------------------------------------------- + + // Circular artist card (ArtistCard.slint: 160x220, 120px circle + name). + component SearchArtistCard: Rectangle { + property var card: ({}) + width: 160 + height: 220 + color: "transparent" + Column { + anchors.horizontalCenter: parent.horizontalCenter + spacing: 8 + Item { width: 1; height: 24 } + Rectangle { + width: 120 + height: 120 + radius: 60 + anchors.horizontalCenter: parent.horizontalCenter + color: theme.surfaceElevated + clip: true + RoundedImage { + anchors.fill: parent + source: card.artPath || "" + radius: 60 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.openArtist(card.id) + } + } + Text { + width: 128 + anchors.horizontalCenter: parent.horizontalCenter + text: card.title || "" + color: theme.textPrimary + font.pixelSize: 14 + font.weight: theme.weightMedium + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + Text { + visible: (card.subtitle || "") !== "" + width: 128 + anchors.horizontalCenter: parent.horizontalCenter + text: card.subtitle || "" + color: theme.textMuted + font.pixelSize: 12 + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + } + + // Big (200x246) circular artist card for the Most-popular artist hero + // (ArtistGridCard slot — same look as Library's LibArtistCard). + component SearchArtistHero: Rectangle { + property var card: ({}) + width: 200 + height: 246 + color: "transparent" + Column { + spacing: 0 + Rectangle { + width: 200 + height: 200 + radius: theme.radiusSm + color: theme.surfaceElevated + clip: true + RoundedImage { + x: 5 + y: 5 + width: 190 + height: 190 + source: card.artPath || "" + radius: 95 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.openArtist(card.id) + } + } + Item { width: 1; height: 6 } + Text { + width: 200 + height: 40 + text: card.title || "" + color: theme.textPrimary + font.pixelSize: theme.fontBody - 2 + font.weight: theme.weightMedium + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + } + } + } + + // Track hero (SearchTrackHero: the 200x246 track card + the quality + // label as TEXT under the meta). + component SearchTrackHero: Rectangle { + property var card: ({}) + property string qualityLabel: "" + color: "transparent" + readonly property bool overlayOn: heroArtArea.containsMouse + + Column { + spacing: 0 + Rectangle { + width: 200 + height: 200 + radius: theme.radiusSm + color: theme.surfaceElevated + clip: true + RoundedImage { + anchors.fill: parent + source: card.artPath || "" + radius: theme.radiusSm + } + Rectangle { + anchors.fill: parent + radius: theme.radiusSm + color: "#000000" + opacity: parent.parent.overlayOn ? 0.6 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + } + Rectangle { + anchors.centerIn: parent + width: 44 + height: 44 + radius: 22 + opacity: parent.parent.overlayOn ? 1.0 : 0.0 + color: heroPlayArea.containsMouse ? "#d6ffffff" : "#ffffff" + QbzIcon { name: "play-fill"; width: 18; height: 18; anchors.centerIn: parent; tintName: "black" } + MouseArea { + id: heroPlayArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.playTrack(card.id) + } + } + MouseArea { + id: heroArtArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.playTrack(card.id) + } + } + Item { width: 1; height: 6 } + Text { + width: 200 + height: 20 + text: card.title || "" + color: theme.textPrimary + font.pixelSize: theme.fontBody - 2 + font.weight: theme.weightMedium + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Text { + width: 200 + height: 18 + text: QbzBridge.tr("Track") + " • " + (card.artist || "") + color: theme.textMuted + font.pixelSize: theme.fontLink - 1 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Text { + visible: qualityLabel !== "" + width: 200 + text: qualityLabel + color: theme.textMuted + font.pixelSize: 11 + elide: Text.ElideRight + } + } + } + + // Playlist card (PlaylistCard.slint meta — cover + title + subtitle). + component SearchPlaylistCard: Rectangle { + property var card: ({}) + width: 200 + height: 246 + color: "transparent" + Column { + spacing: 0 + Rectangle { + width: 200 + height: 200 + radius: theme.radiusSm + color: theme.surfaceElevated + clip: true + RoundedImage { + anchors.fill: parent + source: card.artPath || "" + radius: theme.radiusSm + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + // Playlist view: out of scope (POC-NOTE — inert). + } + } + Item { width: 1; height: 6 } + Text { + width: 200 + height: 20 + text: card.title || "" + color: theme.textPrimary + font.pixelSize: theme.fontBody - 2 + font.weight: theme.weightSemibold + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Text { + width: 200 + height: 16 + text: card.subtitle || "" + color: theme.textMuted + font.pixelSize: theme.fontLink - 1 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } + + // Track row (primitives/TrackRow.slint, 50px — number/play, title + + // explicit, artist, duration, quality, ⋯ menu). + component SearchTrackRow: Rectangle { + property var item: ({}) + property int number: 0 + width: parent ? parent.width : 0 + height: 50 + radius: 6 + color: strArea.containsMouse ? theme.surfaceHover : "transparent" + + MouseArea { + id: strArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.playTrack(item.id) + } + Row { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 14 + Rectangle { + width: 32 + height: parent.height + color: "transparent" + Text { + visible: !strArea.containsMouse + anchors.centerIn: parent + text: number + color: theme.textMuted + font.pixelSize: 13 + } + Rectangle { + visible: strArea.containsMouse + anchors.centerIn: parent + width: 28 + height: 28 + radius: 14 + color: "#3dffffff" + QbzIcon { name: "play-fill"; width: 14; height: 14; anchors.centerIn: parent; tintName: "primary" } + } + } + Column { + width: parent.width - 32 - 70 - 92 - 28 - 3 * 14 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Row { + spacing: 6 + Text { + width: Math.min(implicitWidth, parent.parent.width - (item.explicit ? 22 : 0)) + text: item.title + color: theme.textPrimary + font.pixelSize: 14 + font.weight: theme.weightMedium + elide: Text.ElideRight + } + Rectangle { + visible: item.explicit + width: 16 + height: 16 + radius: 3 + anchors.verticalCenter: parent.verticalCenter + color: theme.surfaceElevated + Text { anchors.centerIn: parent; text: "E"; color: theme.textMuted; font.pixelSize: 10; font.weight: theme.weightSemibold } + } + } + Text { + width: parent.width + text: item.artist + color: theme.textMuted + font.pixelSize: 12 + elide: Text.ElideRight + } + } + Text { + width: 70 + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignRight + text: item.duration + color: theme.textMuted + font.pixelSize: 12 + } + Rectangle { + width: 92 + height: parent.height + color: "transparent" + Row { + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + Image { + visible: item.qualityTier === "hires" + source: "assets/hi-res.svg" + width: 42 + height: 28 + anchors.verticalCenter: parent.verticalCenter + sourceSize: Qt.size(84, 56) + fillMode: Image.PreserveAspectFit + } + Rectangle { + visible: item.qualityTier === "cd" + width: 30 + height: 30 + radius: 3 + color: theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + QbzIcon { name: "cd"; width: 16; height: 16; anchors.centerIn: parent; tintName: "muted" } + } + } + } + Rectangle { + width: 28 + height: 28 + radius: 6 + anchors.verticalCenter: parent.verticalCenter + color: stmArea.containsMouse ? theme.surfaceElevated : "transparent" + QbzIcon { name: "ellipsis"; width: 15; height: 15; anchors.centerIn: parent; tintName: "secondary" } + MouseArea { + id: stmArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: function (mouse) { strMenu.openAtCursor(stmArea, mouse.x, mouse.y) } + } + } + } + QbzContextMenu { + id: strMenu + menuWidth: 196 + Repeater { + model: [ + { "label": QbzBridge.tr("Play"), "icon": "play-fill", "action": "play" }, + { "label": QbzBridge.tr("Play next"), "icon": "list-start", "action": "next" }, + { "label": QbzBridge.tr("Play later"), "icon": "list-plus", "action": "later" }, + { "label": QbzBridge.tr("Add to queue"), "icon": "list-end", "action": "queue" }, + { "label": item.artistId !== "" ? QbzBridge.tr("Go to artist") : "", "icon": "user", "action": "go-artist" }, + { "label": item.albumId !== "" ? QbzBridge.tr("Go to album") : "", "icon": "disc", "action": "go-album" }, + ] + delegate: Rectangle { + required property var modelData + visible: modelData.label !== "" + width: parent ? parent.width : 0 + height: visible ? 33 : 0 + radius: 5 + color: smiArea.containsMouse ? theme.surfaceHover : "transparent" + Row { + anchors.fill: parent + anchors.leftMargin: 8 + spacing: 8 + QbzIcon { name: modelData.icon; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" } + Text { + height: parent.height + text: modelData.label + color: theme.textSecondary + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + MouseArea { + id: smiArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + strMenu.close() + var a = modelData.action + if (a === "play") QbzBridge.playTrack(item.id) + else if (a === "next") QbzBridge.enqueueTrack(item.id, "next") + else if (a === "later") QbzBridge.enqueueTrack(item.id, "later") + else if (a === "queue") QbzBridge.enqueueTrack(item.id, "queue") + else if (a === "go-artist") QbzBridge.openArtist(item.artistId) + else if (a === "go-album") QbzBridge.openAlbum(item.albumId) + } + } + } + } + } + } + + // Section header with an optional "View all" (SectionHeader). + component SectionHeader: Item { + property string title: "" + property bool showMore: false + signal moreClicked() + width: parent ? parent.width : 0 + height: 30 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: title + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + } + Rectangle { + visible: showMore + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: moreText.implicitWidth + 16 + height: 26 + radius: 4 + color: moreArea.containsMouse ? theme.surfaceHover : "transparent" + Text { + id: moreText + anchors.centerIn: parent + text: QbzBridge.tr("View all") + color: theme.accent + font.pixelSize: 14 + font.weight: theme.weightMedium + } + MouseArea { + id: moreArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: parent.parent.moreClicked() + } + } + } + + // "Load more (loaded / total)" (LoadMoreButton). + component LoadMoreButton: Item { + property int loaded: 0 + property int total: 0 + width: parent ? parent.width : 0 + visible: loaded < total + height: visible ? 44 : 0 + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + y: 6 + width: lmText.implicitWidth + 36 + height: 32 + radius: theme.radiusSm + color: lmArea.containsMouse ? theme.surfaceElevated : theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + Text { + id: lmText + anchors.centerIn: parent + text: QbzBridge.tr("Load more") + " (" + loaded + " / " + total + ")" + color: theme.textSecondary + font.pixelSize: 13 + } + MouseArea { + id: lmArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.searchLoadMore(root.tab) + } + } + } + + // searchType filter radio (FilterRadio: 14px ring + 8px accent dot). + component FilterRadio: Item { + property string label: "" + property bool selected: false + signal picked() + width: frRow.width + height: 22 + Row { + id: frRow + spacing: 6 + height: parent.height + Rectangle { + width: 14 + height: 14 + radius: 7 + anchors.verticalCenter: parent.verticalCenter + color: "transparent" + border.width: 1.5 + border.color: parent.parent.selected ? theme.accent : theme.textMuted + Rectangle { + width: 8 + height: 8 + radius: 4 + anchors.centerIn: parent + color: parent.parent.selected ? theme.accent : "transparent" + } + } + Text { + height: parent.height + text: parent.parent.label + color: (parent.parent.selected || frArea.containsMouse) ? theme.textPrimary : theme.textSecondary + font.pixelSize: 12 + verticalAlignment: Text.AlignVCenter + } + } + MouseArea { + id: frArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: parent.picked() + } + } + + // ============================ the view ================================ + Column { + anchors.fill: parent + spacing: 0 + + // --- Row 1: title (left) + filter radios (right) ------------------- + Item { + width: parent.width + height: 56 + Text { + x: 32 + anchors.verticalCenter: parent.verticalCenter + text: QbzBridge.tr("Search") + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightBold + } + Row { + visible: root.hasResults + anchors.right: parent.right + anchors.rightMargin: 32 + anchors.verticalCenter: parent.verticalCenter + spacing: 16 + FilterRadio { label: QbzBridge.tr("Main Artist"); selected: root.filterIndex === 1; onPicked: QbzBridge.searchFilterChanged(1) } + FilterRadio { label: QbzBridge.tr("Performer"); selected: root.filterIndex === 2; onPicked: QbzBridge.searchFilterChanged(2) } + FilterRadio { label: QbzBridge.tr("Composer"); selected: root.filterIndex === 3; onPicked: QbzBridge.searchFilterChanged(3) } + FilterRadio { label: QbzBridge.tr("Label"); selected: root.filterIndex === 4; onPicked: QbzBridge.searchFilterChanged(4) } + FilterRadio { label: QbzBridge.tr("Release Name"); selected: root.filterIndex === 5; onPicked: QbzBridge.searchFilterChanged(5) } + Rectangle { + visible: root.filterIndex !== 0 + width: 24 + height: 24 + radius: 12 + anchors.verticalCenter: parent.verticalCenter + color: clrArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + QbzIcon { name: "x"; width: 13; height: 13; anchors.centerIn: parent; tintName: clrArea.containsMouse ? "primary" : "muted" } + MouseArea { + id: clrArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.searchFilterChanged(0) + } + } + } + } + + // --- Row 2: the five-tab strip (collapsed during the initial load) -- + Item { + visible: !(root.loading && !root.hasResults) + width: parent.width + height: 40 + Row { + x: 32 + anchors.bottom: parent.bottom + anchors.bottomMargin: 8 + spacing: 22 + Repeater { + model: [ + { "t": 0, "label": QbzBridge.tr("All") }, + { "t": 1, "label": QbzBridge.tr("Albums") }, + { "t": 2, "label": QbzBridge.tr("Tracks") }, + { "t": 3, "label": QbzBridge.tr("Artists") }, + { "t": 4, "label": QbzBridge.tr("Playlists") }, + ] + delegate: Column { + required property var modelData + spacing: 5 + Text { + text: modelData.label + color: root.tab === modelData.t ? theme.textPrimary + : (stabArea.containsMouse ? theme.textSecondary : theme.textMuted) + font.pixelSize: 14 + font.weight: root.tab === modelData.t ? theme.weightBold : theme.weightMedium + MouseArea { + id: stabArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.searchTabChanged(modelData.t) + } + } + Rectangle { + width: parent.width + height: 2 + radius: 1 + color: root.tab === modelData.t ? theme.accent : "transparent" + } + } + } + } + } + + // --- Scrollable body ------------------------------------------------ + Item { + width: parent.width + height: parent.height - 56 - 40 + Flickable { + id: bodyFlick + anchors.fill: parent + contentWidth: width + contentHeight: bodyCol.height + 32 + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + id: bodyCol + x: 32 + width: bodyFlick.width - 64 + spacing: 28 + + // Fade in once the search completes (SearchResultsView). + opacity: root.loading ? 0.0 : 1.0 + Behavior on opacity { NumberAnimation { duration: 280; easing.type: Easing.InOutQuad } } + + // ---- Most popular + Artists (All tab) ------------------ + Row { + visible: root.tab === 0 && (root.mp.kind || "") !== "" + width: parent.width + spacing: 24 + // Hero column, fixed 200px (the carousel always starts + // at the same x regardless of the hero kind). + Column { + width: 200 + spacing: 12 + Row { + spacing: 8 + QbzIcon { name: "award"; width: 18; height: 18; tintName: "warning" } + Text { + text: QbzBridge.tr("Most popular") + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + } + } + Item { + width: 200 + height: 246 + SearchArtistHero { + visible: root.mp.kind === "artist" + anchors.horizontalCenter: parent.horizontalCenter + card: root.mp.artist || ({}) + } + AlbumCard { + visible: root.mp.kind === "album" + anchors.horizontalCenter: parent.horizontalCenter + albumId: (root.mp.album || ({})).id || "" + title: (root.mp.album || ({})).title || "" + artist: (root.mp.album || ({})).artist || "" + artistId: (root.mp.album || ({})).artistId || "" + genre: (root.mp.album || ({})).genre || "" + year: (root.mp.album || ({})).year || "" + qualityTier: (root.mp.album || ({})).qualityTier || "" + artSource: (root.mp.album || ({})).artPath || "" + isFavorite: false + isPinned: false + } + SearchTrackHero { + visible: root.mp.kind === "track" + anchors.horizontalCenter: parent.horizontalCenter + card: root.mp.track || ({}) + qualityLabel: root.mp.qualityLabel || "" + } + } + } + // Artists carousel — fills the rest of the row. + Column { + visible: root.artistsCarousel.length > 0 + width: parent.width - 224 + spacing: 12 + SectionHeader { + title: QbzBridge.tr("Artists") + showMore: true + onMoreClicked: QbzBridge.searchTabChanged(3) + } + ListView { + width: parent.width + height: 220 + orientation: ListView.Horizontal + spacing: 32 + clip: true + boundsBehavior: Flickable.StopAtBounds + model: root.artistsCarousel + delegate: SearchArtistCard { card: modelData } + } + } + } + // Artists carousel when there is no most-popular hero. + Column { + visible: root.tab === 0 && (root.mp.kind || "") === "" && root.artists.length > 0 + width: parent.width + spacing: 12 + SectionHeader { + title: QbzBridge.tr("Artists") + showMore: true + onMoreClicked: QbzBridge.searchTabChanged(3) + } + ListView { + width: parent.width + height: 220 + orientation: ListView.Horizontal + spacing: 32 + clip: true + boundsBehavior: Flickable.StopAtBounds + model: root.artists + delegate: SearchArtistCard { card: modelData } + } + } + + // ---- Albums -------------------------------------------- + Column { + visible: root.tab === 0 && root.albums.length > 0 + width: parent.width + spacing: 12 + SectionHeader { + title: QbzBridge.tr("Albums") + showMore: true + onMoreClicked: QbzBridge.searchTabChanged(1) + } + ListView { + width: parent.width + height: 246 + orientation: ListView.Horizontal + spacing: 32 + clip: true + boundsBehavior: Flickable.StopAtBounds + model: root.albums + delegate: AlbumCard { + albumId: modelData.id + title: modelData.title + artist: modelData.artist + artistId: modelData.artistId + genre: modelData.genre + year: modelData.year + qualityTier: modelData.qualityTier + artSource: modelData.artPath || "" + isFavorite: false + isPinned: false + } + } + } + // Albums tab: the full grid + Load more. + GridView { + visible: root.tab === 1 + width: parent.width + height: Math.max(0, Math.ceil(count / Math.max(1, Math.floor((width + 24) / 224))) * 270 - 24) + cellWidth: 224 + cellHeight: 270 + clip: true + boundsBehavior: Flickable.StopAtBounds + interactive: false + model: root.albums + delegate: AlbumCard { + albumId: modelData.id + title: modelData.title + artist: modelData.artist + artistId: modelData.artistId + genre: modelData.genre + year: modelData.year + qualityTier: modelData.qualityTier + artSource: modelData.artPath || "" + isFavorite: false + isPinned: false + } + } + LoadMoreButton { + visible: root.tab === 1 + loaded: root.albums.length + total: root.doc.albumsTotal || 0 + } + + // ---- Tracks -------------------------------------------- + Column { + visible: (root.tab === 0 || root.tab === 2) && root.tracks.length > 0 + width: parent.width + spacing: 4 + SectionHeader { + title: QbzBridge.tr("Tracks") + showMore: root.tab === 0 && (root.doc.tracksTotal || 0) > root.previewCap + onMoreClicked: QbzBridge.searchTabChanged(2) + } + Repeater { + model: root.tab === 2 ? root.tracks : root.tracks.slice(0, root.previewCap) + delegate: SearchTrackRow { item: modelData; number: index + 1 } + } + } + LoadMoreButton { + visible: root.tab === 2 + loaded: root.tracks.length + total: root.doc.tracksTotal || 0 + } + + // ---- Artists grid (per-type tab) ------------------------ + GridView { + visible: root.tab === 3 + width: parent.width + height: Math.max(0, Math.ceil(count / Math.max(1, Math.floor((width + 16) / 176))) * 236 - 16) + cellWidth: 176 + cellHeight: 236 + clip: true + boundsBehavior: Flickable.StopAtBounds + interactive: false + model: root.artists + delegate: SearchArtistCard { card: modelData } + } + LoadMoreButton { + visible: root.tab === 3 + loaded: root.artists.length + total: root.doc.artistsTotal || 0 + } + + // ---- Playlists ------------------------------------------ + Column { + visible: root.tab === 0 && root.playlists.length > 0 + width: parent.width + spacing: 12 + SectionHeader { + title: QbzBridge.tr("Playlists") + showMore: true + onMoreClicked: QbzBridge.searchTabChanged(4) + } + ListView { + width: parent.width + height: 246 + orientation: ListView.Horizontal + spacing: 32 + clip: true + boundsBehavior: Flickable.StopAtBounds + model: root.playlists + delegate: SearchPlaylistCard { card: modelData } + } + } + GridView { + visible: root.tab === 4 + width: parent.width + height: Math.max(0, Math.ceil(count / Math.max(1, Math.floor((width + 24) / 224))) * 270 - 24) + cellWidth: 224 + cellHeight: 270 + clip: true + boundsBehavior: Flickable.StopAtBounds + interactive: false + model: root.playlists + delegate: SearchPlaylistCard { card: modelData } + } + LoadMoreButton { + visible: root.tab === 4 + loaded: root.playlists.length + total: root.doc.playlistsTotal || 0 + } + } + + // ---- Loading + empty states --------------------------------- + Item { + visible: root.loading && !root.hasResults + anchors.fill: parent + Column { + anchors.centerIn: parent + spacing: 18 + QbzSpinner { size: 36; anchors.horizontalCenter: parent.horizontalCenter } + Text { + text: QbzBridge.tr("Searching…") + color: theme.textMuted + font.pixelSize: 13 + } + } + } + Text { + visible: !root.loading && !root.hasResults + x: 32 + y: 8 + text: QbzBridge.tr("No results.") + color: theme.textMuted + font.pixelSize: 14 + } + } + QbzScrollBar { + target: bodyFlick + anchors.right: parent.right + anchors.rightMargin: 4 + anchors.top: parent.top + anchors.bottom: parent.bottom + } + } + } +} diff --git a/crates/qbz-qt/src/auth_qt.rs b/crates/qbz-qt/src/auth_qt.rs index f87d18328..9d04276f2 100644 --- a/crates/qbz-qt/src/auth_qt.rs +++ b/crates/qbz-qt/src/auth_qt.rs @@ -152,6 +152,8 @@ where crate::library_qt::init_local_favorites(&dir); // Phase 7: pinned-items store (AlbumCard pin badges). crate::sidebar_qt::init_pinned(&dir); + // Phase 15: intelligent-search service (cortinilla cache+ranking). + crate::search_qt::init(&dir, crate::search_qt::intelligent_search_pref()); } crate::offline_fwd::subscription_mark_valid(); crate::offline_fwd::engine().set_offline_session(false); @@ -249,6 +251,8 @@ where crate::offline_fwd::init_for_user(&dir); crate::library_qt::init_local_favorites(&dir); crate::sidebar_qt::init_pinned(&dir); + // Phase 15: intelligent-search service (cortinilla cache+ranking). + crate::search_qt::init(&dir, crate::search_qt::intelligent_search_pref()); } crate::offline_fwd::subscription_mark_valid(); crate::offline_fwd::engine().set_offline_session(false); @@ -313,6 +317,8 @@ where crate::library_qt::init_local_favorites(&dir); // Phase 7: pinned-items store (AlbumCard pin badges). crate::sidebar_qt::init_pinned(&dir); + // Phase 15: intelligent-search service (cortinilla cache+ranking). + crate::search_qt::init(&dir, crate::search_qt::intelligent_search_pref()); } crate::offline_fwd::engine().set_offline_session(true); Ok(user_id) diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs index cfe63fe33..e8b907654 100644 --- a/crates/qbz-qt/src/bridge.rs +++ b/crates/qbz-qt/src/bridge.rs @@ -174,6 +174,21 @@ pub mod qbz_bridge { #[qproperty(f32, ambient_surface_alpha)] #[qproperty(f32, ambient_bar_alpha)] + // --- Search (phase 15) --------------------------------------------- + // Cortinilla (live dropdown): open/loading flags, ONE JSON payload + // (search_qt.rs CortinillaData: query/top/sections with controller + // flat indices), keyboard selection + its content-space scroll y. + #[qproperty(bool, cortinilla_open)] + #[qproperty(bool, cortinilla_loading)] + #[qproperty(QString, cortinilla_json)] + #[qproperty(i32, selected_index)] + #[qproperty(f32, cortinilla_scroll_y)] + // Results page: ONE JSON document (search_qt.rs SearchPageDoc). + #[qproperty(QString, search_json)] + // The intelligent-search kill switch (ui_prefs pref state — the + // app-menu check; live-flippable, no restart). + #[qproperty(bool, intelligent_search)] + type QbzBridge = super::QbzBridgeRust; /// Called once from Main.qml's Component.onCompleted: registers the @@ -414,6 +429,42 @@ pub mod qbz_bridge { /// unlike the titlebar). #[qinvokable] fn toggle_ambient_background(self: Pin<&mut QbzBridge>); + + // --- Search (phase 15) --------------------------------------------- + /// Header field keystrokes (QML-debounced 220ms, >= 2 chars): drive + /// the cortinilla live query. + #[qinvokable] + fn search_live(self: Pin<&mut QbzBridge>, query: QString); + /// Enter with the cortinilla closed: full results page (All tab). + #[qinvokable] + fn search_submit(self: Pin<&mut QbzBridge>, query: QString); + /// Cortinilla: Esc / click-outside / idle-close / page change. + #[qinvokable] + fn cortinilla_dismiss(self: Pin<&mut QbzBridge>); + /// Arrow keys: delta -1 (up) / +1 (down) through the flat list. + #[qinvokable] + fn cortinilla_move_selection(self: Pin<&mut QbzBridge>, delta: i32); + /// Row click or Enter on the keyboard-selected row. + #[qinvokable] + fn cortinilla_row_clicked(self: Pin<&mut QbzBridge>, index: i32); + /// Section "View more" (kind: album | track | artist | playlist). + #[qinvokable] + fn cortinilla_view_more(self: Pin<&mut QbzBridge>, kind: QString); + /// The Enter affordance with no keyboard selection: Search > All. + #[qinvokable] + fn cortinilla_search_all(self: Pin<&mut QbzBridge>); + /// Results page: the five-tab strip. + #[qinvokable] + fn search_tab_changed(self: Pin<&mut QbzBridge>, tab: i32); + /// Per-type tab "Load more". + #[qinvokable] + fn search_load_more(self: Pin<&mut QbzBridge>, tab: i32); + /// searchType filter radios (0 = none, 1..5 = the chips). + #[qinvokable] + fn search_filter_changed(self: Pin<&mut QbzBridge>, index: i32); + /// App-menu intelligent-search toggle (the 2.0.0 opt-out module). + #[qinvokable] + fn toggle_intelligent_search(self: Pin<&mut QbzBridge>); } impl cxx_qt::Threading for QbzBridge {} @@ -496,6 +547,13 @@ pub struct QbzBridgeRust { ambient_dim: f32, ambient_surface_alpha: f32, ambient_bar_alpha: f32, + cortinilla_open: bool, + cortinilla_loading: bool, + cortinilla_json: QString, + selected_index: i32, + cortinilla_scroll_y: f32, + search_json: QString, + intelligent_search: bool, } impl Default for QbzBridgeRust { @@ -569,6 +627,13 @@ impl Default for QbzBridgeRust { ambient_dim: crate::settings_qt::ambient_dim(), ambient_surface_alpha: crate::settings_qt::ambient_surface_alpha(), ambient_bar_alpha: crate::settings_qt::ambient_bar_alpha(), + cortinilla_open: false, + cortinilla_loading: false, + cortinilla_json: QString::from("{}"), + selected_index: -1, + cortinilla_scroll_y: 0.0, + search_json: QString::from("{}"), + intelligent_search: crate::search_qt::intelligent_search_pref(), } } } @@ -851,4 +916,48 @@ impl qbz_bridge::QbzBridge { pub fn toggle_ambient_background(self: Pin<&mut Self>) { crate::toggle_ambient_background(); } + + pub fn search_live(self: Pin<&mut Self>, query: QString) { + crate::search_live(query.to_string()); + } + + pub fn search_submit(self: Pin<&mut Self>, query: QString) { + crate::search_submit(query.to_string()); + } + + pub fn cortinilla_dismiss(self: Pin<&mut Self>) { + crate::search_qt::dismiss(); + } + + pub fn cortinilla_move_selection(self: Pin<&mut Self>, delta: i32) { + crate::search_qt::move_selection(delta); + } + + pub fn cortinilla_row_clicked(self: Pin<&mut Self>, index: i32) { + crate::search_qt::row_clicked(index); + } + + pub fn cortinilla_view_more(self: Pin<&mut Self>, kind: QString) { + crate::cortinilla_view_more(kind.to_string()); + } + + pub fn cortinilla_search_all(self: Pin<&mut Self>) { + crate::cortinilla_search_all(); + } + + pub fn search_tab_changed(self: Pin<&mut Self>, tab: i32) { + crate::search_qt::tab_changed(tab); + } + + pub fn search_load_more(self: Pin<&mut Self>, tab: i32) { + crate::search_load_more(tab); + } + + pub fn search_filter_changed(self: Pin<&mut Self>, index: i32) { + crate::search_filter_changed(index); + } + + pub fn toggle_intelligent_search(self: Pin<&mut Self>) { + crate::toggle_intelligent_search(); + } } diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 96f2fc1b0..9e7aa3b06 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -21,6 +21,7 @@ mod artist_qt; mod lyrics_qt; mod playback_qt; mod queue_qt; +mod search_qt; mod settings_qt; mod sidebar_qt; @@ -697,6 +698,54 @@ pub(crate) fn navigate_to(view: &str) { } } +// ============================ Search (phase 15) =========================== + +pub(crate) fn search_live(query: String) { + if offline_fwd::engine().status().is_offline() { + // Qobuz-only surface (the local cortinilla sections are not + // ported — search_qt.rs POC-NOTE): nothing to show offline. + return; + } + let runtime = app(); + spawn(async move { search_qt::live(&runtime, &query).await }); +} + +pub(crate) fn search_submit(query: String) { + if offline_fwd::engine().status().is_offline() { + return; + } + let runtime = app(); + spawn(async move { search_qt::submit(&runtime, &query, None).await }); +} + +pub(crate) fn cortinilla_view_more(kind: String) { + let runtime = app(); + spawn(async move { search_qt::view_more(&runtime, &kind).await }); +} + +pub(crate) fn cortinilla_search_all() { + let runtime = app(); + spawn(async move { search_qt::search_all_action(&runtime).await }); +} + +pub(crate) fn search_load_more(tab: i32) { + let runtime = app(); + spawn(async move { search_qt::load_more(&runtime, tab).await }); +} + +pub(crate) fn search_filter_changed(index: i32) { + let runtime = app(); + spawn(async move { search_qt::filter_changed(&runtime, index).await }); +} + +/// App-menu intelligent-search toggle: persist the pref + flip the live +/// kill switch + update the menu state. Live, no restart. +pub(crate) fn toggle_intelligent_search() { + let next = search_qt::toggle_intelligent_search(); + log::info!("[qbz-qt] intelligent_search -> {next}"); + ui(move |mut b| b.as_mut().set_intelligent_search(next)); +} + // ============================ Settings (phase 10) ========================= /// Publish the settings snapshot (settings_qt.rs SettingsDoc) onto diff --git a/crates/qbz-qt/src/search_qt.rs b/crates/qbz-qt/src/search_qt.rs new file mode 100644 index 000000000..3427679e6 --- /dev/null +++ b/crates/qbz-qt/src/search_qt.rs @@ -0,0 +1,1313 @@ +//! Search controller — the QML port of the Slint search surfaces +//! (crates/qbz/src/search.rs pure mappings + the main.rs SearchActions +//! handlers): +//! +//! - **Cortinilla** (live as-you-type dropdown): `live()` loads the combined +//! search, persists the page into the intelligent-search cache (SWR), picks +//! the learned top result, and publishes ONE JSON document +//! (`cortinillaJson`) with the top-result + capped sections (albums 5 / +//! artists 2 / tracks 3 / playlists 3, spec §6.2.3 order) and controller- +//! assigned flat indices. Keyboard selection (`move_selection`) rebuilds +//! the navigable order from the snapshot exactly like the Slint handler. +//! - **Results page** (`submit` / `search_all_action`): `search_all` mapped +//! to the page document (tabs, totals, most-popular hero, carousel-dedupe), +//! per-category `load_more` (PAGE_SIZE 20) and the searchType filter radios +//! (MainArtist/Performer/Composer/Label/ReleaseName) re-querying the three +//! filterable categories. +//! - **Intelligent Search service** (the 2.0.0 opt-out module): the same +//! headless `qbz_app::settings::search_service::SearchService` the Slint +//! wraps — bound per session (`init`, next to the pinned store), kill +//! switch from `ui_prefs.intelligent_search`, cache `store` on every live +//! load, `record` on row activation, `rank_within` before truncation, +//! `top_for_query` for the promoted row. +//! +//! POC-NOTEs: +//! - The LOCAL "on this device" cortinilla sections (library_db + Plex +//! search, `load_cortinilla`/`append_local_sections`) are NOT ported — +//! the POC's cortinilla/page are Qobuz-only. Offline, the Qobuz call +//! fails and both surfaces show their empty states. +//! - Artist "following" flags come back false on the page (no favorites +//! snapshot is consulted — the follow toggles still hit the API like +//! every other POC surface). +//! - The album/artist blacklist filter is passed empty (the blacklist +//! store is not open in this POC, same as earlier phases). + +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use cxx_qt_lib::QString; +use qbz_app::settings::search_service::{InteractionAction, SearchService}; +use qbz_app::shell::AppRuntime; +use qbz_core::LoggingAdapter; +use qbz_models::{Album, Artist, MostPopularItem, Playlist, SearchAllResults, Track}; +use serde::Serialize; + +// --------------------------------------------------------------------------- +// Intelligent Search service singleton (search_service.rs wrapper, 1:1) +// --------------------------------------------------------------------------- + +static SERVICE: Mutex> = Mutex::new(None); + +/// Bind the per-user service (session activation, next to init_pinned). +/// `enabled` seeds from the persisted ui_prefs.intelligent_search pref. +pub fn init(base_dir: &Path, enabled: bool) { + let service = SearchService::new(base_dir); + service.set_enabled(enabled); + if let Ok(mut guard) = SERVICE.lock() { + *guard = Some(service); + } + log::info!("[qbz-qt] intelligent search service bound (enabled={enabled})"); +} + +fn with_service(default: T, f: impl FnOnce(&SearchService) -> T) -> T { + SERVICE + .lock() + .ok() + .and_then(|guard| guard.as_ref().map(f)) + .unwrap_or(default) +} + +fn with_service_mut(f: impl FnOnce(&mut SearchService)) { + if let Ok(mut guard) = SERVICE.lock() { + if let Some(service) = guard.as_mut() { + f(service); + } + } +} + +pub fn is_enabled() -> bool { + with_service(false, |s| s.enabled()) +} + +pub fn set_enabled(on: bool) { + with_service((), |s| s.set_enabled(on)); +} + +fn store(query: &str, results: &SearchAllResults) { + with_service_mut(|s| s.store(query, results)); +} + +fn record(query: &str, kind: &str, id: &str, action: InteractionAction) { + with_service_mut(|s| s.record_interaction(query, kind, id, action)); +} + +fn top_for_query(query: &str) -> Option<(String, String)> { + with_service(None, |s| s.top_for_query(query)) +} + +fn rank_within(query: &str, kind: &str, items: &mut Vec, id_of: impl Fn(&T) -> String) { + with_service((), |s| s.rank_within(query, kind, items, id_of)); +} + +// --------------------------------------------------------------------------- +// ui_prefs intelligent_search (the 2.0.0 opt-out, additive like the others) +// --------------------------------------------------------------------------- + +pub fn intelligent_search_pref() -> bool { + let Some(path) = dirs::data_dir().map(|d| d.join("qbz").join("ui_prefs.json")) else { + return true; + }; + std::fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|v| v.get("intelligent_search").and_then(|q| q.as_bool())) + .unwrap_or(true) +} + +/// Flip + persist the pref and flip the live kill switch; returns the new value. +pub fn toggle_intelligent_search() -> bool { + let next = !intelligent_search_pref(); + if let Some(path) = dirs::data_dir().map(|d| d.join("qbz").join("ui_prefs.json")) { + let mut value: serde_json::Value = std::fs::read_to_string(&path) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_else(|| serde_json::json!({})); + if let Some(obj) = value.as_object_mut() { + obj.insert("intelligent_search".to_string(), serde_json::Value::Bool(next)); + if let Ok(text) = serde_json::to_string_pretty(&value) { + let _ = std::fs::write(&path, text); + } + } + } + set_enabled(next); + next +} + +// --------------------------------------------------------------------------- +// Row types (search.rs AlbumRow / TrackRowData / ArtistRow / PlaylistRow, +// shaped so the QML cards (AlbumCard etc.) can consume them directly) +// --------------------------------------------------------------------------- + +#[derive(Clone, Default, Serialize)] +pub struct CardRow { + pub id: String, + pub title: String, + pub artist: String, + #[serde(rename = "artistId")] + pub artist_id: String, + pub genre: String, + pub year: String, + #[serde(rename = "qualityTier")] + pub quality_tier: String, + #[serde(rename = "qualityLabel")] + pub quality_label: String, + #[serde(rename = "artUrl")] + pub art_url: String, + #[serde(rename = "artPath")] + pub art_path: String, + #[serde(rename = "isFavorite")] + pub is_favorite: bool, + #[serde(rename = "isPinned")] + pub is_pinned: bool, +} + +#[derive(Clone, Default, Serialize)] +pub struct TrackRow { + pub id: String, + pub title: String, + pub artist: String, + #[serde(rename = "artistId")] + pub artist_id: String, + #[serde(rename = "albumId")] + pub album_id: String, + pub duration: String, + #[serde(rename = "qualityTier")] + pub quality_tier: String, + #[serde(rename = "qualityLabel")] + pub quality_label: String, + #[serde(rename = "qualityDetail")] + pub quality_detail: String, + pub explicit: bool, + #[serde(rename = "artUrl")] + pub art_url: String, + #[serde(rename = "artPath")] + pub art_path: String, + #[serde(rename = "isFavorite")] + pub is_favorite: bool, +} + +#[derive(Clone, Default, Serialize)] +pub struct ArtistRow { + pub id: String, + pub title: String, + pub subtitle: String, + #[serde(rename = "artUrl")] + pub art_url: String, + #[serde(rename = "artPath")] + pub art_path: String, + pub following: bool, +} + +#[derive(Clone, Default, Serialize)] +pub struct PlaylistRow { + pub id: String, + pub title: String, + pub subtitle: String, + #[serde(rename = "artUrl")] + pub art_url: String, + #[serde(rename = "artPath")] + pub art_path: String, + #[serde(rename = "isOwned")] + pub is_owned: bool, + #[serde(rename = "isFollowing")] + pub is_following: bool, +} + +// --------------------------------------------------------------------------- +// Pure helpers (search.rs: tier / quality_label / mmss / year_of / +// format_album_title) +// --------------------------------------------------------------------------- + +fn tier(bit_depth: Option) -> &'static str { + match bit_depth { + Some(depth) if depth >= 24 => "hires", + Some(_) => "cd", + None => "", + } +} + +fn quality_label(bit_depth: Option, sample_rate: Option) -> String { + match bit_depth { + None => String::new(), + Some(depth) => { + let prefix = if depth >= 24 { "Hi-Res" } else { "CD" }; + let rate = sample_rate.unwrap_or(if depth >= 24 { 96.0 } else { 44.1 }); + let rate = if rate.fract().abs() < f64::EPSILON { + format!("{}", rate as i64) + } else { + format!("{rate}") + }; + format!("{prefix} {depth}-bit / {rate} kHz") + } + } +} + +fn mmss(secs: u32) -> String { + format!("{}:{:02}", secs / 60, secs % 60) +} + +fn year_of(date: Option<&str>) -> String { + date.and_then(|d| d.get(0..4)).unwrap_or("").to_string() +} + +fn format_album_title(title: &str, version: Option<&str>) -> String { + match version.map(str::trim).filter(|v| !v.is_empty()) { + Some(v) => format!("{title} ({v})"), + None => title.to_string(), + } +} + +fn map_album(album: &Album) -> CardRow { + CardRow { + id: album.id.clone(), + title: format_album_title(&album.title, album.version.as_deref()), + artist: album.artist.name.clone(), + artist_id: album.artist.id.to_string(), + genre: album + .genre + .clone() + .map(|g| g.name) + .filter(|n| !n.is_empty()) + .unwrap_or_default(), + year: year_of(album.release_date_original.as_deref()), + quality_tier: tier(album.maximum_bit_depth).to_string(), + quality_label: quality_label(album.maximum_bit_depth, album.maximum_sampling_rate), + art_url: album.image.best().cloned().unwrap_or_default(), + ..Default::default() + } +} + +fn map_track(track: &Track) -> TrackRow { + let mut title = track.title.clone(); + if let Some(version) = track.version.as_ref().filter(|v| !v.is_empty()) { + title = format!("{title} ({version})"); + } + let artwork_url = track + .album + .as_ref() + .and_then(|a| a.image.best().cloned()) + .unwrap_or_default(); + let album_id = track.album.as_ref().map(|a| a.id.clone()).unwrap_or_default(); + let (artist, artist_id) = track + .performer + .clone() + .map(|p| (p.name, p.id.to_string())) + .unwrap_or_default(); + TrackRow { + id: track.id.to_string(), + title, + artist, + artist_id, + album_id, + duration: mmss(track.duration), + quality_tier: tier(track.maximum_bit_depth).to_string(), + quality_label: quality_label(track.maximum_bit_depth, track.maximum_sampling_rate), + quality_detail: crate::home_qt::quality_detail_from_parts( + track.maximum_bit_depth, + track.maximum_sampling_rate, + ), + explicit: track.parental_warning, + art_url: artwork_url, + ..Default::default() + } +} + +fn map_artist(artist: &Artist, following: bool) -> ArtistRow { + ArtistRow { + id: artist.id.to_string(), + title: artist.name.clone(), + subtitle: match artist.albums_count { + Some(n) if n > 0 => qbz_i18n::tf("{} album", "{} albums", n as i64, &[&n.to_string()]), + _ => String::new(), + }, + art_url: artist + .image + .as_ref() + .and_then(|i| i.best().cloned()) + .unwrap_or_default(), + art_path: String::new(), + following, + } +} + +fn map_playlist(playlist: &Playlist) -> PlaylistRow { + // Highest-resolution non-empty cover list wins (playlist_cover_urls). + let cover = playlist + .images300 + .as_ref() + .filter(|v| !v.is_empty()) + .or(playlist.images150.as_ref().filter(|v| !v.is_empty())) + .or(playlist.images.as_ref().filter(|v| !v.is_empty())) + .and_then(|v| v.first().cloned()) + .unwrap_or_default(); + let mut subtitle = playlist.owner.name.clone(); + if playlist.tracks_count > 0 { + let count = playlist.tracks_count; + let tracks_label = qbz_i18n::tf("{} track", "{} tracks", count as i64, &[&count.to_string()]); + subtitle = if subtitle.is_empty() { + tracks_label + } else { + format!("{subtitle} • {tracks_label}") + }; + } + PlaylistRow { + id: playlist.id.to_string(), + title: playlist.name.clone(), + subtitle, + art_url: cover, + ..Default::default() + } +} + +// --------------------------------------------------------------------------- +// Artwork (the reload_home pattern: disk hits inline, one background +// download + republish) +// --------------------------------------------------------------------------- + +/// Resolve cover URLs into the row's art_path from the disk cache; returns +/// the urls still missing (deduped). The slot pair is (source url, art_path) +/// — the path starts empty and is filled on a cache hit (artwork_qt's +/// attach_cached pattern for Home). +fn attach_urls(pairs: Vec<(String, &mut String)>) -> Vec { + let mut missing = Vec::new(); + for (url, slot) in pairs { + if url.is_empty() { + continue; + } + let hit = crate::artwork_qt::cached_path(&url); + if hit.is_empty() { + if !missing.contains(&url) { + missing.push(url); + } + } else { + *slot = hit; + } + } + missing +} + +// --------------------------------------------------------------------------- +// Cortinilla payload (search.rs CortRow / CortSection / CortinillaData) +// --------------------------------------------------------------------------- + +#[derive(Clone, Default, Serialize, PartialEq)] +pub struct CortRow { + pub kind: String, + pub id: String, + pub source: String, + pub title: String, + pub subtitle: String, + #[serde(rename = "artUrl")] + pub art_url: String, + #[serde(rename = "artPath")] + pub art_path: String, + #[serde(rename = "flatIndex")] + pub flat_index: i32, +} + +#[derive(Clone, Default, Serialize, PartialEq)] +pub struct CortSection { + pub title: String, + pub kind: String, + #[serde(rename = "hasMore")] + pub has_more: bool, + pub rows: Vec, +} + +#[derive(Clone, Default, Serialize, PartialEq)] +pub struct CortinillaData { + pub query: String, + pub top: Option, + pub sections: Vec, +} + +const CORTINILLA_CAP_ALBUMS: usize = 5; +const CORTINILLA_CAP_ARTISTS: usize = 2; +const CORTINILLA_CAP_TRACKS: usize = 3; +const CORTINILLA_CAP_PLAYLISTS: usize = 3; + +/// search.rs `map_search_all_to_cortinilla`, 1:1 (ranking, caps, top-result +/// selection, flat-index assignment). +fn map_search_all_to_cortinilla(query: &str, results: &SearchAllResults) -> CortinillaData { + let to_artist_row = |a: &Artist| CortRow { + kind: "artist".into(), + id: a.id.to_string(), + source: "qobuz".into(), + title: a.name.clone(), + subtitle: map_artist(a, false).subtitle, + art_url: a + .image + .as_ref() + .and_then(|i| i.best().cloned()) + .unwrap_or_default(), + ..Default::default() + }; + let to_album_row = |al: &Album| { + let m = map_album(al); + CortRow { + kind: "album".into(), + id: m.id, + source: "qobuz".into(), + title: m.title, + subtitle: m.artist, + art_url: m.art_url, + ..Default::default() + } + }; + let to_track_row = |t: &Track| { + let m = map_track(t); + CortRow { + kind: "track".into(), + id: m.id, + source: "qobuz".into(), + title: m.title, + subtitle: m.artist, + art_url: m.art_url, + ..Default::default() + } + }; + let to_playlist_row = |p: &Playlist| { + let m = map_playlist(p); + CortRow { + kind: "playlist".into(), + id: m.id, + source: "qobuz".into(), + title: m.title, + subtitle: m.subtitle, + art_url: m.art_url, + ..Default::default() + } + }; + + let mut artist_rows: Vec = results.artists.items.iter().map(&to_artist_row).collect(); + let mut album_rows: Vec = results.albums.items.iter().map(&to_album_row).collect(); + let mut track_rows: Vec = results.tracks.items.iter().map(&to_track_row).collect(); + let mut playlist_rows: Vec = + results.playlists.items.iter().map(&to_playlist_row).collect(); + + rank_within(query, "artist", &mut artist_rows, |r| r.id.clone()); + rank_within(query, "album", &mut album_rows, |r| r.id.clone()); + rank_within(query, "track", &mut track_rows, |r| r.id.clone()); + rank_within(query, "playlist", &mut playlist_rows, |r| r.id.clone()); + artist_rows.truncate(CORTINILLA_CAP_ARTISTS); + album_rows.truncate(CORTINILLA_CAP_ALBUMS); + track_rows.truncate(CORTINILLA_CAP_TRACKS); + playlist_rows.truncate(CORTINILLA_CAP_PLAYLISTS); + + // Top result: the learned (kind, id) for this query, else the + // most_popular hero, else first artist, else first album. + let top_kind_id = top_for_query(query); + let find_in = |kind: &str, id: &str| -> Option { + let sect = match kind { + "artist" => &artist_rows, + "album" => &album_rows, + "track" => &track_rows, + "playlist" => &playlist_rows, + _ => return None, + }; + sect.iter().find(|r| r.id == id).cloned() + }; + let top: Option = top_kind_id + .and_then(|(kind, id)| { + find_in(&kind, &id).or_else(|| match kind.as_str() { + "artist" => results + .artists + .items + .iter() + .find(|a| a.id.to_string() == id) + .map(&to_artist_row), + "album" => results.albums.items.iter().find(|a| a.id == id).map(&to_album_row), + "track" => results.tracks.items.iter().find(|t| t.id.to_string() == id).map(&to_track_row), + "playlist" => results + .playlists + .items + .iter() + .find(|p| p.id.to_string() == id) + .map(&to_playlist_row), + _ => None, + }) + }) + .or_else(|| match &results.most_popular { + Some(MostPopularItem::Artists(a)) => Some(to_artist_row(a)), + Some(MostPopularItem::Albums(a)) => Some(to_album_row(a)), + Some(MostPopularItem::Tracks(t)) => Some(to_track_row(t)), + None => None, + }) + .or_else(|| artist_rows.first().cloned()) + .or_else(|| album_rows.first().cloned()); + + let mut sections: Vec = Vec::new(); + let mut push_section = |title: &str, kind: &str, rows: Vec, total: u32| { + if !rows.is_empty() { + sections.push(CortSection { + title: title.to_string(), + kind: kind.to_string(), + has_more: total as usize > rows.len(), + rows, + }); + } + }; + push_section(&qbz_i18n::t("Albums"), "album", album_rows, results.albums.total); + push_section(&qbz_i18n::t("Artists"), "artist", artist_rows, results.artists.total); + push_section(&qbz_i18n::t("Tracks"), "track", track_rows, results.tracks.total); + push_section(&qbz_i18n::t("Playlists"), "playlist", playlist_rows, results.playlists.total); + + let mut data = CortinillaData { + query: query.to_string(), + top, + sections, + }; + assign_flat_indices(&mut data); + data +} + +fn assign_flat_indices(data: &mut CortinillaData) { + let mut next = 0; + if let Some(top) = &mut data.top { + top.flat_index = next; + next += 1; + } else { + next = 1; // no top result: section rows start at 1 (Slint convention) + } + for section in &mut data.sections { + for row in &mut section.rows { + row.flat_index = next; + next += 1; + } + } +} + +// --------------------------------------------------------------------------- +// Cortinilla controller state +// --------------------------------------------------------------------------- + +static LAST_CORT: Mutex> = Mutex::new(None); +static CORT_VERSION: AtomicU64 = AtomicU64::new(0); +/// The keyboard-selected flat index mirrored from the bridge property (so +/// `move_selection` needs no QML round-trip). +static CURRENT_SEL: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1); + +fn next_cort_version() -> u64 { + CORT_VERSION.fetch_add(1, Ordering::SeqCst) + 1 +} +fn is_current_cort_version(v: u64) -> bool { + CORT_VERSION.load(Ordering::SeqCst) == v +} + +fn set_cortinilla_open(open: bool) { + crate::ui(move |mut b| { + b.as_mut().set_cortinilla_open(open); + if !open { + b.as_mut().set_selected_index(-1); + } + }); +} + +fn set_selected(index: i32, scroll_y: f32) { + CURRENT_SEL.store(index, Ordering::SeqCst); + crate::ui(move |mut b| { + b.as_mut().set_selected_index(index); + b.as_mut().set_cortinilla_scroll_y(scroll_y); + }); +} + +/// The live header query changed (QML debounced at 220ms, >= 2 chars — +/// mirrors the Slint CORTINILLA_DEBOUNCE + the chars().count() gate). +/// Opens the dropdown, resets the keyboard selection, and loads. +pub async fn live(runtime: &Arc>, query: &str) { + let q = query.trim().to_string(); + if q.chars().count() < 2 { + set_cortinilla_open(false); + next_cort_version(); + return; + } + if !is_enabled() { + // Module OFF: the Slint live-navigates to the results page instead + // (300ms debounce). The POC keeps the cortinilla-only surface: + // Enter still reaches the page via submit(). POC-NOTE. + return; + } + set_selected(-1, 0.0); + set_cortinilla_open(true); + let version = next_cort_version(); + // Query echo + loading flag ride the cortinillaLoading property and a + // minimal payload (skeleton rows are pure QML). + crate::ui(move |mut b| { + b.as_mut().set_cortinilla_loading(true); + }); + + let t = std::time::Instant::now(); + let results = runtime + .core() + .search_all(&q, &Default::default(), &Default::default()) + .await; + let results = match results { + Ok(r) => r, + Err(e) => { + log::error!("[qbz-qt] cortinilla load failed: {e}"); + crate::ui(move |mut b| { + if is_current_cort_version(version) { + b.as_mut().set_cortinilla_loading(false); + } + }); + return; + } + }; + log::info!( + "[qbz-qt][perf] cortinilla query \"{q}\" -> results: {:?}", + t.elapsed(), + ); + store(&q, &results); + let mut data = map_search_all_to_cortinilla(&q, &results); + // Artwork: disk hits inline; misses download in the background and + // republish the SAME payload (version-guarded). + let mut urls: Vec<(String, &mut String)> = Vec::new(); + if let Some(top) = &mut data.top { + urls.push((top.art_url.clone(), &mut top.art_path)); + } + for section in &mut data.sections { + for row in &mut section.rows { + urls.push((row.art_url.clone(), &mut row.art_path)); + } + } + let missing = attach_urls(urls); + *LAST_CORT.lock().unwrap() = Some(data.clone()); + crate::ui(move |mut b| { + if is_current_cort_version(version) { + let json = serde_json::to_string(&data).unwrap_or_else(|_| "{}".into()); + b.as_mut().set_cortinilla_json(QString::from(json.as_str())); + b.as_mut().set_cortinilla_loading(false); + } + }); + if !missing.is_empty() { + crate::spawn(async move { + crate::artwork_qt::download_missing(missing).await; + if !is_current_cort_version(version) { + return; + } + let mut data = LAST_CORT.lock().unwrap().clone(); + if let Some(data) = &mut data { + let mut urls: Vec<(String, &mut String)> = Vec::new(); + if let Some(top) = &mut data.top { + urls.push((top.art_url.clone(), &mut top.art_path)); + } + for section in &mut data.sections { + for row in &mut section.rows { + urls.push((row.art_url.clone(), &mut row.art_path)); + } + } + let _ = attach_urls(urls); + let data = data.clone(); + *LAST_CORT.lock().unwrap() = Some(data.clone()); + crate::ui(move |mut b| { + let json = serde_json::to_string(&data).unwrap_or_else(|_| "{}".into()); + b.as_mut().set_cortinilla_json(QString::from(json.as_str())); + }); + } + }); + } +} + +/// Dismiss (Esc / click-outside / idle / page change). +pub fn dismiss() { + set_cortinilla_open(false); +} + +/// Arrow-key move (delta -1 up / +1 down; the Slint on_cortinilla_move_selection +/// semantics: Down from -1 -> first, Up from first -> -1, clamp both ends). +pub fn move_selection(delta: i32) { + let current = CURRENT_SEL.load(Ordering::SeqCst); + let snap = LAST_CORT.lock().unwrap().clone(); + let Some(data) = snap else { return }; + let mut order: Vec = Vec::new(); + if let Some(top) = &data.top { + order.push(top.flat_index); + } + for section in &data.sections { + for row in §ion.rows { + order.push(row.flat_index); + } + } + if order.is_empty() { + return; + } + let pos = order.iter().position(|&fi| fi == current); + let new_index = if delta > 0 { + match pos { + None => order[0], + Some(p) if p + 1 < order.len() => order[p + 1], + Some(_) => order[order.len() - 1], + } + } else { + match pos { + None => -1, + Some(0) => -1, + Some(p) => order[p - 1], + } + }; + // Content-space top-y of the selected row (the QML scrolls it into + // view). Layout mirrors the QML cortinilla: 6px top padding; the + // top-result block is 4 + 22 + 56; each section is 4 + 24 header + + // 56/row. + let scroll_y = flat_index_content_y(&data, new_index); + set_selected(new_index, scroll_y); +} + +fn flat_index_content_y(data: &CortinillaData, flat_index: i32) -> f32 { + let mut y = 6.0f32; + if let Some(top) = &data.top { + if top.flat_index == flat_index { + return y + 4.0 + 22.0; + } + y += 4.0 + 22.0 + 56.0; + } + for section in &data.sections { + y += 4.0 + 24.0; + for row in §ion.rows { + if row.flat_index == flat_index { + return y; + } + y += 56.0; + } + } + 0.0 +} + +/// Click / Enter on a row: record the interaction (Capa B) and dispatch by +/// kind (album -> album view, artist -> artist view, track -> play, +/// playlist -> inert POC-NOTE). The QML clears the input + closes itself. +pub fn row_clicked(flat_index: i32) { + let snap = LAST_CORT.lock().unwrap().clone(); + let Some(data) = snap else { return }; + let row = data + .top + .iter() + .chain(data.sections.iter().flat_map(|s| s.rows.iter())) + .find(|r| r.flat_index == flat_index) + .cloned(); + let Some(row) = row else { return }; + set_cortinilla_open(false); + if row.source != "local" { + let action = if row.kind == "track" { + InteractionAction::Play + } else { + InteractionAction::Open + }; + record(&data.query, &row.kind, &row.id, action); + } + match row.kind.as_str() { + "album" => crate::open_album(row.id), + "artist" => crate::open_artist(row.id), + "track" => { + if let Ok(id) = row.id.parse::() { + crate::play_track(id); + } + } + // playlist: no playlist view in the POC (POC-NOTE — inert). + _ => {} + } +} + +/// "View more" on a section: full search page on the matching tab +/// (album -> 1, track -> 2, artist -> 3, playlist -> 4). +pub async fn view_more(runtime: &Arc>, kind: &str) { + let tab = match kind { + "album" => 1, + "track" => 2, + "artist" => 3, + "playlist" => 4, + _ => 0, + }; + let q = LAST_CORT.lock().unwrap().as_ref().map(|d| d.query.clone()).unwrap_or_default(); + set_cortinilla_open(false); + submit(runtime, &q, Some(tab)).await; +} + +/// The Enter affordance with no keyboard selection: full search, All tab. +pub async fn search_all_action(runtime: &Arc>) { + let q = LAST_CORT.lock().unwrap().as_ref().map(|d| d.query.clone()).unwrap_or_default(); + set_cortinilla_open(false); + submit(runtime, &q, Some(0)).await; +} + +// --------------------------------------------------------------------------- +// Results page +// --------------------------------------------------------------------------- + +#[derive(Clone, Default, Serialize)] +pub struct MostPopularDoc { + pub kind: String, + pub album: Option, + pub artist: Option, + pub track: Option, + #[serde(rename = "qualityLabel")] + pub quality_label: String, +} + +#[derive(Clone, Default, Serialize)] +pub struct SearchPageDoc { + pub query: String, + pub tab: i32, + pub loading: bool, + #[serde(rename = "filterIndex")] + pub filter_index: i32, + pub albums: Vec, + pub tracks: Vec, + pub artists: Vec, + #[serde(rename = "artistsCarousel")] + pub artists_carousel: Vec, + pub playlists: Vec, + #[serde(rename = "albumsTotal")] + pub albums_total: i32, + #[serde(rename = "tracksTotal")] + pub tracks_total: i32, + #[serde(rename = "artistsTotal")] + pub artists_total: i32, + #[serde(rename = "playlistsTotal")] + pub playlists_total: i32, + #[serde(rename = "mostPopular")] + pub most_popular: MostPopularDoc, +} + +#[derive(Default)] +struct PageState { + doc: SearchPageDoc, +} + +static PAGE: Mutex> = Mutex::new(None); +static PAGE_VERSION: AtomicU64 = AtomicU64::new(0); + +fn next_page_version() -> u64 { + PAGE_VERSION.fetch_add(1, Ordering::SeqCst) + 1 +} +fn is_current_page_version(v: u64) -> bool { + PAGE_VERSION.load(Ordering::SeqCst) == v +} + +fn publish_page(doc: &SearchPageDoc) { + let json = serde_json::to_string(doc).unwrap_or_else(|_| "{}".into()); + crate::ui(move |mut b| { + b.as_mut().set_search_json(QString::from(json.as_str())); + }); +} + +/// search.rs `search_type_for_filter`. +fn search_type_for_filter(index: i32) -> Option { + match index { + 1 => Some("MainArtist".into()), + 2 => Some("Performer".into()), + 3 => Some("Composer".into()), + 4 => Some("Label".into()), + 5 => Some("ReleaseName".into()), + _ => None, + } +} + +/// Enter (or View-more / search-all): record the nav entry and run the full +/// combined search. `tab` is always All on plain submit; View-more picks +/// the section's tab (the Slint lands on the tab, the QML clears the header +/// input itself). +pub async fn submit(runtime: &Arc>, query: &str, tab: Option) { + let q = query.trim().to_string(); + if q.chars().count() < 2 { + return; + } + let version = next_page_version(); + crate::navigate_to("search"); + { + let mut guard = PAGE.lock().unwrap(); + let doc = &mut guard.get_or_insert_with(PageState::default).doc; + doc.query = q.clone(); + doc.tab = tab.unwrap_or(0); + doc.loading = true; + publish_page(doc); + } + + let t = std::time::Instant::now(); + let results = runtime + .core() + .search_all(&q, &Default::default(), &Default::default()) + .await; + if !is_current_page_version(version) { + return; + } + let results = match results { + Ok(r) => r, + Err(e) => { + log::warn!("[qbz-qt] search failed: {e}"); + let mut guard = PAGE.lock().unwrap(); + let doc = &mut guard.get_or_insert_with(PageState::default).doc; + doc.loading = false; + doc.albums.clear(); + doc.tracks.clear(); + doc.artists.clear(); + doc.artists_carousel.clear(); + doc.playlists.clear(); + doc.albums_total = 0; + doc.tracks_total = 0; + doc.artists_total = 0; + doc.playlists_total = 0; + doc.most_popular = MostPopularDoc::default(); + publish_page(doc); + return; + } + }; + log::info!( + "[qbz-qt][perf] search page \"{q}\" -> results: {:?} ({}+{}+{}+{})", + t.elapsed(), + results.albums.items.len(), + results.tracks.items.len(), + results.artists.items.len(), + results.playlists.items.len(), + ); + + let mut albums: Vec = results.albums.items.iter().map(map_album).collect(); + let mut tracks: Vec = results.tracks.items.iter().map(map_track).collect(); + let mut artists: Vec = results.artists.items.iter().map(|a| map_artist(a, false)).collect(); + let mut playlists: Vec = results.playlists.items.iter().map(map_playlist).collect(); + + let (mp_kind, mut mp_album, mut mp_artist, mut mp_track, mp_quality) = match &results.most_popular { + Some(MostPopularItem::Albums(a)) => ("album".to_string(), Some(map_album(a)), None, None, quality_label(a.maximum_bit_depth, a.maximum_sampling_rate)), + Some(MostPopularItem::Artists(a)) => ("artist".to_string(), None, Some(map_artist(a, false)), None, String::new()), + Some(MostPopularItem::Tracks(t)) => { + ("track".to_string(), None, None, Some(map_track(t)), quality_label(t.maximum_bit_depth, t.maximum_sampling_rate)) + } + None => (String::new(), None, None, None, String::new()), + }; + let mut missing: Vec = Vec::new(); + missing.extend(attach_urls(albums.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect())); + missing.extend(attach_urls(tracks.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect())); + missing.extend(attach_urls(artists.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect())); + missing.extend(attach_urls(playlists.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect())); + if let Some(a) = &mut mp_album { + missing.extend(attach_urls(vec![(a.art_url.clone(), &mut a.art_path)])); + } + if let Some(a) = &mut mp_artist { + missing.extend(attach_urls(vec![(a.art_url.clone(), &mut a.art_path)])); + } + if let Some(t) = &mut mp_track { + missing.extend(attach_urls(vec![(t.art_url.clone(), &mut t.art_path)])); + } + missing.dedup(); + // The All-tab carousel skips the top-result artist when it is also the + // first list entry (apply_search dedupe) — derived AFTER artwork attach + // so its rows carry the disk paths (they are clones of `artists` rows). + let artists_carousel: Vec = match (&mp_kind, artists.first()) { + (kind, Some(first)) if kind == "artist" && mp_artist.as_ref().is_some_and(|m| m.id == first.id) => { + artists[1..].to_vec() + } + _ => artists.clone(), + }; + + let doc = { + let mut guard = PAGE.lock().unwrap(); + let doc = &mut guard.get_or_insert_with(PageState::default).doc; + doc.loading = false; + doc.albums = albums; + doc.tracks = tracks; + doc.artists_carousel = artists_carousel; + doc.artists = artists; + doc.playlists = playlists; + doc.albums_total = results.albums.total as i32; + doc.tracks_total = results.tracks.total as i32; + doc.artists_total = results.artists.total as i32; + doc.playlists_total = results.playlists.total as i32; + doc.most_popular = MostPopularDoc { + kind: mp_kind, + album: mp_album, + artist: mp_artist, + track: mp_track, + quality_label: mp_quality, + }; + doc.clone() + }; + publish_page(&doc); + + if !missing.is_empty() { + crate::spawn(async move { + crate::artwork_qt::download_missing(missing).await; + if !is_current_page_version(version) { + return; + } + let doc = { + let mut guard = PAGE.lock().unwrap(); + let doc = &mut guard.get_or_insert_with(PageState::default).doc; + let mut missing2 = Vec::new(); + missing2.extend(attach_urls(doc.albums.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect())); + missing2.extend(attach_urls(doc.tracks.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect())); + missing2.extend(attach_urls(doc.artists.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect())); + missing2.extend(attach_urls(doc.playlists.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect())); + // The carousel shares rows with `artists`; artPath lives on + // the ArtistRow structs, so refresh the carousel from the + // updated list. + doc.artists_carousel = doc + .artists_carousel + .iter() + .map(|c| { + doc.artists + .iter() + .find(|a| a.id == c.id) + .cloned() + .unwrap_or_else(|| c.clone()) + }) + .collect(); + if let Some(a) = &mut doc.most_popular.album { + let _ = attach_urls(vec![(a.art_url.clone(), &mut a.art_path)]); + } + if let Some(a) = &mut doc.most_popular.artist { + let _ = attach_urls(vec![(a.art_url.clone(), &mut a.art_path)]); + } + if let Some(t) = &mut doc.most_popular.track { + let _ = attach_urls(vec![(t.art_url.clone(), &mut t.art_path)]); + } + let _ = missing2; + doc.clone() + }; + publish_page(&doc); + }); + } +} + +/// Tab strip: pure view state (search_all already loaded everything). +pub fn tab_changed(tab: i32) { + let mut guard = PAGE.lock().unwrap(); + if let Some(page) = guard.as_mut() { + page.doc.tab = tab; + let doc = page.doc.clone(); + drop(guard); + publish_page(&doc); + } +} + +/// search.rs PAGE_SIZE (matches the Tauri search page size). +const PAGE_SIZE: u32 = 20; + +/// Load more rows for the active per-type tab (offset = rows already loaded). +pub async fn load_more(runtime: &Arc>, tab: i32) { + let (query, filter, offset) = { + let guard = PAGE.lock().unwrap(); + let Some(page) = guard.as_ref() else { return }; + let offset = match tab { + 1 => page.doc.albums.len(), + 2 => page.doc.tracks.len(), + 3 => page.doc.artists.len(), + 4 => page.doc.playlists.len(), + _ => return, + }; + (page.doc.query.clone(), page.doc.filter_index, offset as u32) + }; + let search_type = search_type_for_filter(filter); + let version = next_page_version(); + + match tab { + 1 => match runtime.core().search_albums(&query, PAGE_SIZE, offset, search_type.as_deref()).await { + Ok(page) => { + let mut rows: Vec = page.items.iter().map(map_album).collect(); + let missing = attach_urls(rows.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect()); + let total = page.total as i32; + let mut guard = PAGE.lock().unwrap(); + if let Some(p) = guard.as_mut() { + if is_current_page_version(version) { + p.doc.albums.extend(rows); + p.doc.albums_total = total; + let doc = p.doc.clone(); + drop(guard); + publish_page(&doc); + } + } + let _ = missing; + } + Err(e) => log::error!("[qbz-qt] search load-more albums failed: {e}"), + }, + 2 => match runtime.core().search_tracks(&query, PAGE_SIZE, offset, search_type.as_deref()).await { + Ok(page) => { + let mut rows: Vec = page.items.iter().map(map_track).collect(); + let missing = attach_urls(rows.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect()); + let total = page.total as i32; + let mut guard = PAGE.lock().unwrap(); + if let Some(p) = guard.as_mut() { + if is_current_page_version(version) { + p.doc.tracks.extend(rows); + p.doc.tracks_total = total; + let doc = p.doc.clone(); + drop(guard); + publish_page(&doc); + } + } + let _ = missing; + } + Err(e) => log::error!("[qbz-qt] search load-more tracks failed: {e}"), + }, + 3 => match runtime.core().search_artists(&query, PAGE_SIZE, offset, search_type.as_deref()).await { + Ok(page) => { + let mut rows: Vec = page.items.iter().map(|a| map_artist(a, false)).collect(); + let missing = attach_urls(rows.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect()); + let total = page.total as i32; + let mut guard = PAGE.lock().unwrap(); + if let Some(p) = guard.as_mut() { + if is_current_page_version(version) { + p.doc.artists.extend(rows); + p.doc.artists_total = total; + let doc = p.doc.clone(); + drop(guard); + publish_page(&doc); + } + } + let _ = missing; + } + Err(e) => log::error!("[qbz-qt] search load-more artists failed: {e}"), + }, + 4 => match runtime.core().search_playlists(&query, PAGE_SIZE, offset).await { + Ok(page) => { + let mut rows: Vec = page.items.iter().map(map_playlist).collect(); + let missing = attach_urls(rows.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect()); + let total = page.total as i32; + let mut guard = PAGE.lock().unwrap(); + if let Some(p) = guard.as_mut() { + if is_current_page_version(version) { + p.doc.playlists.extend(rows); + p.doc.playlists_total = total; + let doc = p.doc.clone(); + drop(guard); + publish_page(&doc); + } + } + let _ = missing; + } + Err(e) => log::error!("[qbz-qt] search load-more playlists failed: {e}"), + }, + _ => {} + } +} + +/// searchType filter radios: re-query the three filterable categories and +/// REPLACE their lists (the filter takes effect on every tab). +pub async fn filter_changed(runtime: &Arc>, index: i32) { + let query = { + let mut guard = PAGE.lock().unwrap(); + let Some(page) = guard.as_mut() else { return }; + page.doc.filter_index = index; + let doc = page.doc.clone(); + publish_page(&doc); + doc.query + }; + if query.trim().is_empty() { + return; + } + let search_type = search_type_for_filter(index); + let version = next_page_version(); + + // Albums. + if let Ok(page) = runtime.core().search_albums(&query, PAGE_SIZE, 0, search_type.as_deref()).await { + let mut rows: Vec = page.items.iter().map(map_album).collect(); + let _ = attach_urls(rows.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect()); + let total = page.total as i32; + let mut guard = PAGE.lock().unwrap(); + if let Some(p) = guard.as_mut() { + if is_current_page_version(version) { + p.doc.albums = rows; + p.doc.albums_total = total; + let doc = p.doc.clone(); + drop(guard); + publish_page(&doc); + } + } + } + // Tracks. + if let Ok(page) = runtime.core().search_tracks(&query, PAGE_SIZE, 0, search_type.as_deref()).await { + let mut rows: Vec = page.items.iter().map(map_track).collect(); + let _ = attach_urls(rows.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect()); + let total = page.total as i32; + let mut guard = PAGE.lock().unwrap(); + if let Some(p) = guard.as_mut() { + if is_current_page_version(version) { + p.doc.tracks = rows; + p.doc.tracks_total = total; + let doc = p.doc.clone(); + drop(guard); + publish_page(&doc); + } + } + } + // Artists (the API takes the search_type too, 1:1 the Slint filter). + if let Ok(page) = runtime.core().search_artists(&query, PAGE_SIZE, 0, search_type.as_deref()).await { + let mut rows: Vec = page.items.iter().map(|a| map_artist(a, false)).collect(); + let _ = attach_urls(rows.iter_mut().map(|r| (r.art_url.clone(), &mut r.art_path)).collect()); + let total = page.total as i32; + let mut guard = PAGE.lock().unwrap(); + if let Some(p) = guard.as_mut() { + if is_current_page_version(version) { + p.doc.artists = rows.clone(); + p.doc.artists_total = total; + p.doc.artists_carousel = rows; + let doc = p.doc.clone(); + drop(guard); + publish_page(&doc); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn search_type_for_filter_maps_dropdown_index() { + assert_eq!(search_type_for_filter(0), None); + assert_eq!(search_type_for_filter(1), Some("MainArtist".to_string())); + assert_eq!(search_type_for_filter(3), Some("Composer".to_string())); + assert_eq!(search_type_for_filter(5), Some("ReleaseName".to_string())); + assert_eq!(search_type_for_filter(99), None); + } + + #[test] + fn flat_indices_skip_zero_without_top_result() { + let mut data = CortinillaData { + query: "q".into(), + top: None, + sections: vec![CortSection { + title: "Albums".into(), + kind: "album".into(), + has_more: false, + rows: vec![ + CortRow { kind: "album".into(), id: "1".into(), ..Default::default() }, + CortRow { kind: "album".into(), id: "2".into(), ..Default::default() }, + ], + }], + }; + assign_flat_indices(&mut data); + assert_eq!(data.sections[0].rows[0].flat_index, 1); + assert_eq!(data.sections[0].rows[1].flat_index, 2); + } + + #[test] + fn flat_indices_top_is_zero() { + let mut data = CortinillaData { + query: "q".into(), + top: Some(CortRow { kind: "artist".into(), id: "9".into(), ..Default::default() }), + sections: vec![CortSection { + title: "Albums".into(), + kind: "album".into(), + has_more: false, + rows: vec![CortRow { kind: "album".into(), id: "1".into(), ..Default::default() }], + }], + }; + assign_flat_indices(&mut data); + assert_eq!(data.top.as_ref().unwrap().flat_index, 0); + assert_eq!(data.sections[0].rows[0].flat_index, 1); + } + + #[test] + fn quality_label_formats() { + assert_eq!(quality_label(Some(24), Some(96.0)), "Hi-Res 24-bit / 96 kHz"); + assert_eq!(quality_label(Some(16), None), "CD 16-bit / 44.1 kHz"); + assert_eq!(quality_label(None, Some(192.0)), ""); + assert_eq!(mmss(5), "0:05"); + assert_eq!(mmss(225), "3:45"); + assert_eq!(tier(Some(24)), "hires"); + assert_eq!(tier(Some(16)), "cd"); + assert_eq!(tier(None), ""); + } +}