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:
10 files changed
+2936
-5
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -42,6 +42,7 @@ ApplicationWindow {
|
||||
|
||||
|
||||
|
||||
|
||||
Loader {
|
||||
id: screenLoader
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user