feat(qbz-qt): phase 15 — search 1:1 (cortinilla + full page with filters)

Intelligent search engine reused from qbz_app (SWR cache, interaction
recording, rank_within, top_for_query; app-menu kill switch on the
shared pref). Header field fully live: 220ms debounce, keyboard
semantics (arrows/Enter/Esc), clear, idle-close. Cortinilla as a plain
overlay (pointer stays on the field — Slint's exact reason): top
result, spec-ordered sections with View more, skeleton, row activation
dispatches (album/artist views, track play). Search page: 5 searchType
filter radios (Main Artist/Performer/Composer/Label/Release Name),
5 scope tabs, Most-popular hero, carousels, per-type grids/lists with
paged Load more. Perf: cortinilla 245ms cached / 1.1s cold. Fixed an
artwork attach bug the screenshots exposed (url/path slot mixup).
18/18 tests.

km
This commit is contained in:
vicrodh committed 2026-07-28 00:49:53 -06:00
1 parent 9f86a40f52
commit 9eb46d5e25
10 files changed
+2936 -5

No files matched your search

+2
View File
@@ -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",
+9 -1
View File
@@ -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
}
}
+356
View File
@@ -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)
}
}
}
}
+125 -4
View File
@@ -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")
+1
View File
@@ -42,6 +42,7 @@ ApplicationWindow {
Loader {
id: screenLoader
anchors.fill: parent
+966
View File
@@ -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
}
}
}
}
+6
View File
@@ -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)
+109
View File
@@ -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();
}
}
+49
View File
@@ -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
File diff suppressed because it is too large. Load diff