refactor(qbz-qt): phase 23 part 1 — QML module layout + first five domain bridges
Two fronts of the modularization, both gated green before this commit. QML leaves the flat qml/ directory for seven modules: cards, controls, rows, settings, shell, theme, views. Only Main.qml and LoginScreen.qml stay at the root. build.rs matches the tree exactly (55 files), relative imports and asset paths were re-anchored for the new depth. The QbzBridge God-object starts breaking up: QbzSession, QbzShell, QbzPlayer, QbzQueue and QbzHome are now their own #[cxx_qt::bridge] singletons, each with its own OnceLock<CxxQtThread> and ui() hop, booted in order from Main.qml. Bridge files stay directly under src/ because cxx-qt-build only accepts rust_files from one directory (QTBUG-93443). bridge.rs keeps 5.5 domains (library, album/artist, settings, integrations, search, playlist) and dies once they are split out. cc
This commit is contained in:
72 files changed
+2152
-1690
No files matched your search
@@ -0,0 +1,723 @@
|
||||
// Album detail page — QML port of album/AlbumPageView.slint.
|
||||
//
|
||||
// Header (224px cover, title, credited-artist line, meta with label link,
|
||||
// description + Read more, CircleAction row), divider, toolbar (quality
|
||||
// badge + track search), column header, track list (Disc/work headers,
|
||||
// TrackRow replica with the playing-row pill + number↔play cell + live
|
||||
// heart), label/awards sidebar, and the two bottom carousels ("From the
|
||||
// same artist", "Listening suggestions").
|
||||
//
|
||||
// POC-NOTEs: multi-select + bulk bar, offline download column, booklet,
|
||||
// external links, custom-cover menu, album-info modal, the header
|
||||
// atmosphere (album-header-gradient pref) — out of scope (visible stubs
|
||||
// are inert). Text keeps the theme colors (header-light = false here).
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import com.blitzfc.qbz
|
||||
import "../controls"
|
||||
import "../rows"
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
// Transparent while the ambient background is active (phase 14 —
|
||||
// HomeView.slint:163: the frosted content panel shows through).
|
||||
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
|
||||
// Round to the AppShell content-frame bezel (Radius.md): QML clips
|
||||
// are rectangular, so the frame's own rounding never reaches the
|
||||
// view — the view's own fill must round instead.
|
||||
radius: 12
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// The view's album + url-keyed cover map (artwork pipeline).
|
||||
readonly property var album: JSON.parse(QbzBridge.albumJson)
|
||||
readonly property var header: album.header || ({})
|
||||
readonly property var tracks: album.tracks || []
|
||||
readonly property var awards: album.awards || []
|
||||
property var coverMap: ({})
|
||||
// Client-side track search (AlbumActions.search equivalent).
|
||||
property string trackQuery: ""
|
||||
|
||||
readonly property var visibleTracks: {
|
||||
if (trackQuery === "") return tracks
|
||||
var q = trackQuery.toLowerCase()
|
||||
return tracks.filter(function (t) {
|
||||
return t.title.toLowerCase().indexOf(q) >= 0
|
||||
})
|
||||
}
|
||||
|
||||
// Disc headers + work headers precede their first row (computed once
|
||||
// per track list change, mirroring AlbumState's disc-header-number /
|
||||
// work-header model fields).
|
||||
function headerFor(i) {
|
||||
var t = visibleTracks[i]
|
||||
if (!t) return null
|
||||
if (i === 0) {
|
||||
return { disc: t.disc > 1 || (visibleTracks.length > 0 && visibleTracks[visibleTracks.length - 1].disc > 1) ? t.disc : 0,
|
||||
work: t.workHeader }
|
||||
}
|
||||
var prev = visibleTracks[i - 1]
|
||||
var multi = visibleTracks[visibleTracks.length - 1].disc > 1
|
||||
return { disc: (multi && t.disc !== prev.disc) ? t.disc : 0,
|
||||
work: t.workHeader !== prev.workHeader ? t.workHeader : "" }
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: QbzBridge
|
||||
function onLibraryArtworkReady(key, path) {
|
||||
var m = root.coverMap
|
||||
m[key] = path
|
||||
root.coverMap = Object.assign({}, m)
|
||||
}
|
||||
}
|
||||
Component.onCompleted: dispatchCovers()
|
||||
onAlbumChanged: dispatchCovers()
|
||||
// The derived binding settles AFTER onAlbumChanged fires (stale race) —
|
||||
// redispatch when the header itself updates.
|
||||
onHeaderChanged: dispatchCovers()
|
||||
function dispatchCovers() {
|
||||
var urls = []
|
||||
if (header && header.artUrl) urls.push(header.artUrl)
|
||||
var more = album.moreFromArtist || []
|
||||
for (var i = 0; i < more.length; i++) if (more[i].artUrl) urls.push(more[i].artUrl)
|
||||
var sug = album.suggestions || []
|
||||
for (i = 0; i < sug.length; i++) if (sug[i].artUrl) urls.push(sug[i].artUrl)
|
||||
if (urls.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
|
||||
}
|
||||
|
||||
// Ghost CircleAction (secondary, on-surface variant): elevated disc,
|
||||
// strong ring, text-primary icon (accent when active).
|
||||
|
||||
|
||||
// Track list row (TrackRow.slint replica: number cell, no artwork,
|
||||
// Sidebar label/award card (SidebarCard).
|
||||
component SidebarCard: Rectangle {
|
||||
property string name: ""
|
||||
property color gradA: "#6366f1"
|
||||
property color gradB: "#8b5cf6"
|
||||
property string iconName: "disc"
|
||||
signal clicked()
|
||||
width: parent ? parent.width : 0
|
||||
height: 48
|
||||
radius: theme.radiusSm
|
||||
color: scArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 6
|
||||
spacing: 10
|
||||
Rectangle {
|
||||
width: 28
|
||||
height: 28
|
||||
radius: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: gradA }
|
||||
GradientStop { position: 1.0; color: gradB }
|
||||
}
|
||||
QbzIcon {
|
||||
name: iconName
|
||||
width: 13
|
||||
height: 13
|
||||
anchors.centerIn: parent
|
||||
tintName: "primary"
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: parent.width - 38
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: name
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
font.weight: theme.weightMedium
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: scArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: parent.clicked()
|
||||
}
|
||||
}
|
||||
component SidebarHeading: Text {
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 10
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
// ============================ the page ================================
|
||||
Flickable {
|
||||
id: pageFlick
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: page.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: page
|
||||
width: parent.width
|
||||
leftPadding: 32
|
||||
rightPadding: 32
|
||||
topPadding: 11
|
||||
bottomPadding: 100
|
||||
spacing: 0
|
||||
|
||||
// NavButtons is a 0px placeholder in the Slint source.
|
||||
Item { width: 1; height: 22 }
|
||||
|
||||
// --- Album header -------------------------------------------
|
||||
Row {
|
||||
width: parent.width - 64
|
||||
spacing: 32
|
||||
|
||||
Rectangle {
|
||||
width: 224
|
||||
height: 224
|
||||
radius: 12
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
RoundedImage {
|
||||
anchors.fill: parent
|
||||
source: root.coverMap[header.artUrl] || ""
|
||||
radius: 12
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width - 224 - 32
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 4
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: header.title || ""
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Item { width: 1; height: 4 }
|
||||
// Credited-artist line (links + role suffixes).
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
Repeater {
|
||||
model: header.credits || []
|
||||
delegate: Row {
|
||||
required property var modelData
|
||||
required property int index
|
||||
spacing: 0
|
||||
Text {
|
||||
visible: index > 0
|
||||
text: " • "
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightBold
|
||||
}
|
||||
Text {
|
||||
text: modelData[0]
|
||||
color: creditArea.containsMouse && modelData[1] !== "" ? theme.textPrimary : theme.textSecondary
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightBold
|
||||
MouseArea {
|
||||
id: creditArea
|
||||
anchors.fill: parent
|
||||
enabled: modelData[1] !== ""
|
||||
hoverEnabled: true
|
||||
cursorShape: modelData[1] !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: QbzBridge.openArtist(modelData[1])
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: modelData[2] !== ""
|
||||
text: " (" + modelData[2] + ")"
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontHeading
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Item { width: 1; height: 10 }
|
||||
// Meta line (label as a clickable link when navigable).
|
||||
Row {
|
||||
spacing: 0
|
||||
visible: (header.labelId || "") !== "" && (header.label || "") !== ""
|
||||
Text {
|
||||
visible: (header.metaPre || "") !== ""
|
||||
text: (header.metaPre || "") + " • "
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
}
|
||||
Text {
|
||||
text: header.label || ""
|
||||
color: labelArea.containsMouse ? theme.accent : theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
MouseArea {
|
||||
id: labelArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// POC-NOTE: no label view yet.
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: (header.metaPost || "") !== ""
|
||||
text: " • " + (header.metaPost || "")
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: (header.labelId || "") === "" || (header.label || "") === ""
|
||||
width: parent.width
|
||||
text: header.infoLine || ""
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// Editorial description + Read more.
|
||||
Item { visible: (header.description || "") !== ""; width: 1; height: 12 }
|
||||
Text {
|
||||
visible: (header.description || "") !== ""
|
||||
width: parent.width
|
||||
text: header.descriptionShort || ""
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
Item { visible: (header.description || "") !== (header.descriptionShort || ""); width: 1; height: 4 }
|
||||
Text {
|
||||
visible: (header.description || "") !== (header.descriptionShort || "")
|
||||
text: QbzSession.tr("Read more", QbzSession.trRev)
|
||||
color: readMoreArea.containsMouse ? theme.accentHover : theme.accent
|
||||
font.pixelSize: theme.fontLegal
|
||||
MouseArea {
|
||||
id: readMoreArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
var shell = root.parent
|
||||
while (shell && shell.openTextModal === undefined) shell = shell.parent
|
||||
if (shell) shell.openTextModal(QbzSession.tr("About this album", QbzSession.trRev), header.description || "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { width: 1; height: 20 }
|
||||
// Action row (on-surface CircleActions).
|
||||
Row {
|
||||
spacing: 12
|
||||
// Play — accent disc, white glyph (on-surface primary).
|
||||
Rectangle {
|
||||
width: 44
|
||||
height: 44
|
||||
radius: 22
|
||||
color: playHdrArea.containsMouse ? theme.accentHover : theme.accent
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "play-fill"
|
||||
width: 19
|
||||
height: 19
|
||||
tintName: "primary"
|
||||
}
|
||||
MouseArea {
|
||||
id: playHdrArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzPlayer.playAlbum(header.id)
|
||||
}
|
||||
}
|
||||
QbzCircleAction {
|
||||
name: "shuffle"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzPlayer.playAlbumShuffled(header.id)
|
||||
}
|
||||
QbzCircleAction {
|
||||
name: header.isFavorite ? "heart-filled" : "heart"
|
||||
active: header.isFavorite === true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: {
|
||||
header.isFavorite = !header.isFavorite
|
||||
QbzBridge.libraryToggleFavorite("album", header.id)
|
||||
}
|
||||
}
|
||||
// Radio / Mixtape / Info — INERT stubs (POC-NOTE:
|
||||
// radio engines, mixtape store, album-info modal).
|
||||
QbzCircleAction { name: "radio"; anchors.verticalCenter: parent.verticalCenter }
|
||||
QbzCircleAction { name: "cassette-tape"; anchors.verticalCenter: parent.verticalCenter }
|
||||
QbzCircleAction { name: "info"; anchors.verticalCenter: parent.verticalCenter }
|
||||
QbzCircleAction {
|
||||
id: albumMenuBtn
|
||||
name: "ellipsis"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: function (mouse) { albumMenu.openAtCursor(albumMenuBtn, mouse.x, mouse.y) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { width: 1; height: 20 }
|
||||
// Header divider (the atmosphere ends here in Slint).
|
||||
Rectangle { width: parent.width - 64; height: 1; color: theme.borderSubtle }
|
||||
Item { width: 1; height: 8 }
|
||||
|
||||
// --- Track list + label/awards sidebar ----------------------
|
||||
Row {
|
||||
width: parent.width - 64
|
||||
spacing: 32
|
||||
|
||||
Column {
|
||||
width: parent.width - ((header.label || "") !== "" || awards.length > 0 ? 232 : 0)
|
||||
spacing: 0
|
||||
|
||||
// Loading.
|
||||
Item {
|
||||
visible: QbzBridge.albumLoading && tracks.length === 0
|
||||
width: parent.width
|
||||
height: 280
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 18
|
||||
QbzSpinner { size: 36; anchors.horizontalCenter: parent.horizontalCenter }
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: QbzSession.tr("Loading album…", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toolbar — quality badge + track search (+ inert select).
|
||||
Row {
|
||||
visible: !QbzBridge.albumLoading
|
||||
width: parent.width
|
||||
height: 52
|
||||
spacing: 16
|
||||
Row {
|
||||
id: qualityRow
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
Image {
|
||||
visible: header.qualityTier === "hires"
|
||||
source: "../assets/hi-res.svg"
|
||||
width: 42
|
||||
height: 28
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
sourceSize: Qt.size(84, 56)
|
||||
fillMode: Image.PreserveAspectFit
|
||||
}
|
||||
Text {
|
||||
visible: (header.qualityDetail || "") !== ""
|
||||
text: header.qualityDetail || ""
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
Item { width: parent.width - qualityRow.width - 168 - 30 - 3 * 16; height: 1 }
|
||||
Rectangle {
|
||||
width: 168
|
||||
height: 34
|
||||
radius: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
spacing: 7
|
||||
QbzIcon {
|
||||
name: "search"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "muted"
|
||||
}
|
||||
TextInput {
|
||||
width: parent.width - 21
|
||||
height: parent.height
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
clip: true
|
||||
onTextEdited: root.trackQuery = text
|
||||
Text {
|
||||
visible: parent.text === ""
|
||||
anchors.fill: parent
|
||||
text: QbzSession.tr("Search tracks...", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: 30
|
||||
height: 30
|
||||
radius: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: selectArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
QbzIcon {
|
||||
name: "square-check-big"
|
||||
width: 15
|
||||
height: 15
|
||||
anchors.centerIn: parent
|
||||
tintName: selectArea.containsMouse ? "primary" : "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
id: selectArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// POC-NOTE: multi-select out of scope.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Column header.
|
||||
Row {
|
||||
visible: !QbzBridge.albumLoading
|
||||
width: parent.width
|
||||
height: 40
|
||||
leftPadding: 12
|
||||
rightPadding: 12
|
||||
spacing: 16
|
||||
Text { text: "#"; width: 32; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; anchors.verticalCenter: parent.verticalCenter }
|
||||
Text { text: QbzSession.tr("Title", QbzSession.trRev); width: parent.width - 32 - 80 - 80 - 28 - 28 - 32 - 5 * 16 - 24; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; anchors.verticalCenter: parent.verticalCenter; elide: Text.ElideRight }
|
||||
Text { text: QbzSession.tr("Duration", QbzSession.trRev); width: 80; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; horizontalAlignment: Text.AlignHCenter; anchors.verticalCenter: parent.verticalCenter }
|
||||
Text { text: QbzSession.tr("Quality", QbzSession.trRev); width: 80; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; horizontalAlignment: Text.AlignHCenter; anchors.verticalCenter: parent.verticalCenter }
|
||||
Item { width: 28; height: 1; QbzIcon { name: "heart"; width: 14; height: 14; anchors.centerIn: parent; tintName: "muted" } }
|
||||
Item { width: 28; height: 1; QbzIcon { name: "cloud-download"; width: 14; height: 14; anchors.centerIn: parent; tintName: "muted" } }
|
||||
Item { width: 32; height: 1 }
|
||||
}
|
||||
|
||||
// Rows (with Disc / work headers).
|
||||
Repeater {
|
||||
model: root.visibleTracks
|
||||
delegate: Column {
|
||||
required property var modelData
|
||||
required property int index
|
||||
property var hdr: root.headerFor(index)
|
||||
width: parent ? parent.width : 0
|
||||
|
||||
Rectangle {
|
||||
visible: hdr && hdr.disc > 0
|
||||
width: parent.width
|
||||
height: 40
|
||||
color: "transparent"
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Disc", QbzSession.trRev) + " " + hdr.disc
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
}
|
||||
Row {
|
||||
visible: hdr && hdr.work !== ""
|
||||
width: parent.width
|
||||
leftPadding: 12
|
||||
rightPadding: 12
|
||||
topPadding: 14
|
||||
bottomPadding: 4
|
||||
spacing: 0
|
||||
Text {
|
||||
text: hdr.work
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightBold
|
||||
}
|
||||
Text {
|
||||
visible: modelData.workComposerName !== ""
|
||||
text: " ("
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightBold
|
||||
}
|
||||
Text {
|
||||
visible: modelData.workComposerName !== ""
|
||||
text: modelData.workComposerName
|
||||
color: composerArea.containsMouse && modelData.workComposerId !== "" ? theme.textPrimary : theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightBold
|
||||
MouseArea {
|
||||
id: composerArea
|
||||
anchors.fill: parent
|
||||
enabled: modelData.workComposerId !== ""
|
||||
hoverEnabled: true
|
||||
cursorShape: modelData.workComposerId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: QbzBridge.openArtist(modelData.workComposerId)
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: modelData.workComposerName !== ""
|
||||
text: ")"
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightBold
|
||||
}
|
||||
}
|
||||
TrackRow {
|
||||
item: modelData
|
||||
number: index + 1
|
||||
zebra: true
|
||||
clickPlays: false
|
||||
artistLink: true
|
||||
qualityStyle: "text"
|
||||
showDownload: true
|
||||
downloadGlyph: true
|
||||
menuShowLater: false
|
||||
menuShowGoTo: false
|
||||
onPlayRequested: QbzPlayer.playAlbumFrom(header.id, item.id)
|
||||
onEnqueueRequested: function (m) {
|
||||
QbzPlayer.enqueueAlbumTrack(header.id, item.id, m === "next" ? "next" : "later")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Label / awards sidebar (200px).
|
||||
Column {
|
||||
visible: (header.label || "") !== "" || awards.length > 0
|
||||
width: 200
|
||||
spacing: 24
|
||||
|
||||
Column {
|
||||
visible: (header.label || "") !== ""
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
SidebarHeading { text: QbzSession.tr("LABEL", QbzSession.trRev) }
|
||||
SidebarCard {
|
||||
name: header.label || ""
|
||||
iconName: "disc"
|
||||
gradA: "#6366f1"
|
||||
gradB: "#8b5cf6"
|
||||
// POC-NOTE: no label view yet.
|
||||
}
|
||||
}
|
||||
Column {
|
||||
visible: awards.length > 0
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
SidebarHeading { text: QbzSession.tr("AWARDS", QbzSession.trRev) }
|
||||
Repeater {
|
||||
model: awards
|
||||
delegate: SidebarCard {
|
||||
required property var modelData
|
||||
name: modelData[1]
|
||||
iconName: "award"
|
||||
gradA: "#b45309"
|
||||
gradB: "#eab308"
|
||||
// POC-NOTE: no award view yet.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bottom carousels ---------------------------------------
|
||||
Item { visible: (album.moreFromArtist || []).length > 0; width: 1; height: 40 }
|
||||
SectionRail {
|
||||
visible: (album.moreFromArtist || []).length > 0
|
||||
title: QbzSession.tr("From the same artist", QbzSession.trRev)
|
||||
items: album.moreFromArtist || []
|
||||
coverMap: root.coverMap
|
||||
}
|
||||
Item { visible: (album.suggestions || []).length > 0; width: 1; height: 40 }
|
||||
SectionRail {
|
||||
visible: (album.suggestions || []).length > 0
|
||||
title: QbzSession.tr("Listening suggestions", QbzSession.trRev)
|
||||
items: album.suggestions || []
|
||||
coverMap: root.coverMap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Thin auto-hiding scrollbar (ListScrollbar).
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
target: pageFlick
|
||||
}
|
||||
|
||||
// Album ⋯ menu (AlbumContextMenu subset — card menu + pin).
|
||||
QbzContextMenu {
|
||||
id: albumMenu
|
||||
menuWidth: 196
|
||||
Repeater {
|
||||
model: [
|
||||
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
||||
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-plus", "action": "next" },
|
||||
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
|
||||
{ "label": header.isFavorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": header.isFavorite ? "heart-filled" : "heart", "action": "favorite" },
|
||||
{ "label": header.isPinned ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": header.isPinned ? "pin-filled" : "pin", "action": "pin" },
|
||||
]
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
width: parent ? parent.width : 0
|
||||
height: 33
|
||||
radius: 5
|
||||
color: amiArea.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
|
||||
width: parent.width - 23
|
||||
text: modelData.label
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: amiArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
albumMenu.close()
|
||||
var a = modelData.action
|
||||
if (a === "play") QbzPlayer.playAlbum(header.id)
|
||||
else if (a === "next") QbzPlayer.enqueueAlbum(header.id, "next")
|
||||
else if (a === "queue") QbzPlayer.enqueueAlbum(header.id, "later")
|
||||
else if (a === "favorite") {
|
||||
header.isFavorite = !header.isFavorite
|
||||
QbzBridge.libraryToggleFavorite("album", header.id)
|
||||
} else if (a === "pin") {
|
||||
header.isPinned = !header.isPinned
|
||||
QbzBridge.togglePin("album", header.id, header.title, header.artist, header.artUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,737 @@
|
||||
// Discover > Home — QML port of crates/qbz-ui/ui/discover/HomeView.slint
|
||||
// (+ Carousel / AlbumCard / SlimCarousel / SlimCard / ArtistCarousel /
|
||||
// PlaylistCarousel / HomeSkeleton / OfflinePlaceholder).
|
||||
//
|
||||
// Data: QbzHome.homeSectionsJson (one JSON document — see bridge.rs),
|
||||
// published by src/home_qt.rs; artwork file:// paths resolve through the
|
||||
// qbz-cache image cache. Section kinds: "album" | "playlist" | "slim" |
|
||||
// "artists" | "pinned" | "recentPlaceholder". Rail ORDER + VISIBILITY
|
||||
// follow the persisted Discover prefs (phase 11, discover_prefs.db).
|
||||
//
|
||||
// POC-NOTEs:
|
||||
// - The genre filter + section-configurator gear are INERT visual stubs
|
||||
// (out of scope).
|
||||
// - Editor's Picks / For You mount the same rails, ordered by each tab's
|
||||
// discover prefs (phase 13); Recommendations renders a placeholder — the
|
||||
// external reco engine (external_reco.rs) is not ported. The tab bar is
|
||||
// always fully visible (the Slint showRecommendations gate is not wired;
|
||||
// the pref is ON for this user anyway).
|
||||
// - Card clicks / hover actions (play / favorite / more / pin) and
|
||||
// "View all" / context menus are inert — album/artist pages, playback
|
||||
// and per-user stores are later phases.
|
||||
// - The offline mount mirrors AppShell's ADR-010 seam: OfflineState.offline
|
||||
// -> the OfflinePlaceholder replica (exact msgids).
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import com.blitzfc.qbz
|
||||
import "../cards"
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
// Transparent while the ambient background is active (phase 14 —
|
||||
// HomeView.slint:163: the frosted content panel shows through).
|
||||
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
|
||||
// Round to the AppShell content-frame bezel (Radius.md): QML clips
|
||||
// are rectangular, so the frame's own rounding never reaches the
|
||||
// view — the view's own fill must round instead.
|
||||
radius: 12
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// Reparsed whenever Rust republishes the JSON documents (one per
|
||||
// Discover tab — phase 13).
|
||||
readonly property var sections: JSON.parse(QbzHome.homeSectionsJson)
|
||||
readonly property var editorSections: JSON.parse(QbzHome.editorSectionsJson)
|
||||
readonly property var forYouSections: JSON.parse(QbzHome.forYouSectionsJson)
|
||||
property string activeTab: "home"
|
||||
|
||||
// ============================ shared components =======================
|
||||
|
||||
// Circular page-control button (Carousel's NavButton).
|
||||
// Horizontal album rail (Carousel.slint): header + clipped ListView,
|
||||
// page chevrons (per-page step like the Slint paging).
|
||||
component AlbumRail: Column {
|
||||
property var sectionData: ({})
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 12
|
||||
|
||||
readonly property int perPage: Math.max(1, Math.floor((rail.width + 32) / 232))
|
||||
readonly property int step: perPage * 232
|
||||
readonly property real maxScroll: Math.max(0, rail.contentWidth - rail.width)
|
||||
|
||||
QbzSectionHeader {
|
||||
title: sectionData.title
|
||||
leftEnabled: rail.contentX > 1
|
||||
rightEnabled: rail.contentX < maxScroll - 1
|
||||
showViewAll: (sectionData.endpoint || "") !== ""
|
||||
onPageLeft: rail.contentX = Math.min(0, rail.contentX - step)
|
||||
onPageRight: rail.contentX = Math.min(maxScroll, rail.contentX + step)
|
||||
}
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 246
|
||||
ListView {
|
||||
id: rail
|
||||
anchors.fill: parent
|
||||
orientation: ListView.Horizontal
|
||||
spacing: 32
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: sectionData.items
|
||||
delegate: AlbumCard {
|
||||
albumId: modelData.id
|
||||
title: modelData.title
|
||||
artist: modelData.artist
|
||||
artistId: modelData.artistId
|
||||
genre: modelData.genre
|
||||
year: modelData.year
|
||||
qualityTier: modelData.qualityTier
|
||||
ribbon: modelData.ribbon
|
||||
ribbonKind: modelData.ribbonKind
|
||||
artSource: modelData.artPath
|
||||
isPinned: modelData.isPinned
|
||||
// POC-NOTE: Home hearts are not seeded from fav_cache
|
||||
// (store not open in the POC); toggles still hit the API.
|
||||
isFavorite: false
|
||||
}
|
||||
}
|
||||
// Cider-style edge fades (Carousel.slint): content dissolves
|
||||
// into the page background at the scrolled edges.
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: 56
|
||||
opacity: rail.contentX > 1 ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: theme.surfaceMain }
|
||||
GradientStop { position: 1.0; color: "transparent" }
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: 56
|
||||
opacity: rail.contentX < maxScroll - 1 ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: "transparent" }
|
||||
GradientStop { position: 1.0; color: theme.surfaceMain }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slim rows mount the shared qml/SlimCard.qml (SlimCard.slint).
|
||||
// Popular slim grid (SlimCarousel.slint): 4x3 pages of 12, capped 24.
|
||||
component SlimGrid: Column {
|
||||
property var sectionData: ({})
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 12
|
||||
|
||||
readonly property int perPage: 12
|
||||
readonly property int total: Math.min(sectionData.items.length, 2 * perPage)
|
||||
readonly property int pageCount: Math.max(1, Math.ceil(total / perPage))
|
||||
readonly property real maxScroll: (pageCount - 1) * grid.width
|
||||
|
||||
QbzSectionHeader {
|
||||
title: sectionData.title
|
||||
leftEnabled: grid.contentX > 1
|
||||
rightEnabled: grid.contentX < maxScroll - 1
|
||||
showViewAll: (sectionData.endpoint || "") !== ""
|
||||
onPageLeft: grid.contentX = Math.max(0, grid.contentX - grid.width)
|
||||
onPageRight: grid.contentX = Math.min(maxScroll, grid.contentX + grid.width)
|
||||
}
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 3 * 60 + 2 * 8
|
||||
clip: true
|
||||
Flickable {
|
||||
id: grid
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
contentWidth: pageCount * width
|
||||
contentHeight: height
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
readonly property real cellWidth: (width - 3 * 8) / 4
|
||||
|
||||
Repeater {
|
||||
model: sectionData.items
|
||||
delegate: SlimCard {
|
||||
readonly property int slot: index % perPage
|
||||
readonly property int pageIdx: Math.floor(index / perPage)
|
||||
visible: index < total
|
||||
width: grid.cellWidth
|
||||
height: 60
|
||||
x: pageIdx * grid.width + (slot % 4) * (grid.cellWidth + 8)
|
||||
y: Math.floor(slot / 4) * (60 + 8)
|
||||
card: modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned rail (PinnedCarousel.slint) — one 200x246 slot per item, the
|
||||
// card picked by the item's own kind: albums reuse AlbumCard, artists
|
||||
// render the ArtistGridCard circle, playlists the PlaylistCard square.
|
||||
// Fed from the shared per-user pinned_items.db (home_qt "pinned"
|
||||
// section; most-recent first).
|
||||
component PinnedRail: Column {
|
||||
property var sectionData: ({})
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 12
|
||||
|
||||
readonly property real maxScroll: Math.max(0, rail.contentWidth - rail.width)
|
||||
readonly property int perPage: Math.max(1, Math.floor((rail.width + 32) / 232))
|
||||
readonly property int step: perPage * 232
|
||||
|
||||
QbzSectionHeader {
|
||||
title: sectionData.title
|
||||
leftEnabled: rail.contentX > 1
|
||||
rightEnabled: rail.contentX < maxScroll - 1
|
||||
onPageLeft: rail.contentX = Math.max(0, rail.contentX - step)
|
||||
onPageRight: rail.contentX = Math.min(maxScroll, rail.contentX + step)
|
||||
}
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 246
|
||||
ListView {
|
||||
id: rail
|
||||
anchors.fill: parent
|
||||
orientation: ListView.Horizontal
|
||||
spacing: 32
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: sectionData.items
|
||||
delegate: Item {
|
||||
required property var modelData
|
||||
width: 200
|
||||
height: 246
|
||||
|
||||
Component {
|
||||
id: pAlbum
|
||||
AlbumCard {
|
||||
albumId: modelData.id
|
||||
title: modelData.title
|
||||
artist: modelData.artist
|
||||
artSource: modelData.artPath || ""
|
||||
isPinned: true
|
||||
isFavorite: false
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: pArtist
|
||||
ArtistCard {
|
||||
item: modelData
|
||||
artSource: modelData.artPath || ""
|
||||
isPinned: true
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: pPlaylist
|
||||
PlaylistCard {
|
||||
// Pinned model: { id, title, artist, artPath,
|
||||
// isPinned } — artist maps to the subtitle line.
|
||||
item: Object.assign({}, modelData, { subtitle: modelData.subtitle || modelData.artist || "" })
|
||||
artSource: modelData.artPath || ""
|
||||
isPinned: true
|
||||
}
|
||||
}
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
sourceComponent: modelData.itemKind === "artist" ? pArtist
|
||||
: modelData.itemKind === "playlist" ? pPlaylist : pAlbum
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Qobuz Mixes rail (QobuzMixesRow.slint) — four static 220px
|
||||
// navigation tiles (gradient art + badge + name, description below).
|
||||
// POC-NOTE: the mix DETAIL views are out of scope — tiles are inert.
|
||||
// (Slint's 135° linear gradients are approximated with corner
|
||||
// RadialGradients — QML has no angled linear gradient.)
|
||||
component MixTile: Column {
|
||||
property string badge: ""
|
||||
property string mixName: ""
|
||||
property string desc: ""
|
||||
property color c0: "#000000"
|
||||
property color c1: "#000000"
|
||||
property color c2: "#000000"
|
||||
spacing: 8
|
||||
width: 220
|
||||
|
||||
Rectangle {
|
||||
width: 220
|
||||
height: 220
|
||||
radius: 8
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: c0 }
|
||||
GradientStop { position: 0.5; color: c1 }
|
||||
GradientStop { position: 1.0; color: c2 }
|
||||
}
|
||||
// Fake the 135° sweep with a corner-centered radial overlay.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 8
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: "#00000000" }
|
||||
GradientStop { position: 1.0; color: "#33000000" }
|
||||
}
|
||||
}
|
||||
Text {
|
||||
x: 12
|
||||
y: 12
|
||||
text: badge
|
||||
color: "#ccffffff"
|
||||
font.pixelSize: 10
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: mixName
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 22
|
||||
font.weight: theme.weightBold
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// Inert (mix detail views out of scope).
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: 220
|
||||
text: desc
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
component MixesRail: Column {
|
||||
property var sectionData: ({})
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 12
|
||||
Text {
|
||||
text: sectionData.title
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
Row {
|
||||
spacing: 32
|
||||
MixTile {
|
||||
badge: "qobuz"; mixName: "DailyQ"
|
||||
desc: QbzSession.tr("Elevate your day with a customized selection of music.", QbzSession.trRev)
|
||||
c0: "#1e3a8a"; c1: "#6366f1"; c2: "#c084fc"
|
||||
}
|
||||
MixTile {
|
||||
badge: "qobuz"; mixName: "WeeklyQ"
|
||||
desc: QbzSession.tr("Take a weekly journey with a fresh mix every Friday.", QbzSession.trRev)
|
||||
c0: "#065f46"; c1: "#10b981"; c2: "#fbbf24"
|
||||
}
|
||||
MixTile {
|
||||
badge: "qbz"; mixName: "FavQ"
|
||||
desc: QbzSession.tr("A fresh shuffle from your personal library.", QbzSession.trRev)
|
||||
c0: "#7f1d1d"; c1: "#ef4444"; c2: "#fb923c"
|
||||
}
|
||||
MixTile {
|
||||
badge: "qbz"; mixName: "TopQ"
|
||||
desc: QbzSession.tr("Discover new music from your most-played playlists.", QbzSession.trRev)
|
||||
c0: "#1f2937"; c1: "#4b5563"; c2: "#fbbf24"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The section-rails renderer (one per Discover tab — the tab bodies
|
||||
// differ only in WHICH sections doc they mount).
|
||||
component SectionRails: Column {
|
||||
property var sectionsModel: []
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 40
|
||||
|
||||
Repeater {
|
||||
model: sectionsModel
|
||||
delegate: Loader {
|
||||
required property var modelData
|
||||
width: parent ? parent.width : 0
|
||||
sourceComponent: modelData.kind === "album" ? albumRailComp
|
||||
: modelData.kind === "playlist" ? playlistRailComp
|
||||
: modelData.kind === "slim" ? slimGridComp
|
||||
: modelData.kind === "artists" ? artistRailComp
|
||||
: modelData.kind === "pinned" ? pinnedRailComp
|
||||
: modelData.kind === "mixes" ? mixesRailComp
|
||||
: recentComp
|
||||
property var sectionData: modelData
|
||||
|
||||
Component {
|
||||
id: pinnedRailComp
|
||||
PinnedRail { sectionData: parent.sectionData }
|
||||
}
|
||||
Component {
|
||||
id: albumRailComp
|
||||
AlbumRail { sectionData: parent.sectionData }
|
||||
}
|
||||
Component {
|
||||
id: playlistRailComp
|
||||
Column {
|
||||
property var sectionData: parent.sectionData
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 12
|
||||
Text {
|
||||
text: sectionData.title
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
ListView {
|
||||
width: parent.width
|
||||
height: 246
|
||||
orientation: ListView.Horizontal
|
||||
spacing: 32
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: sectionData.items
|
||||
delegate: PlaylistCard {
|
||||
item: modelData
|
||||
artSource: modelData.artPath || ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: slimGridComp
|
||||
SlimGrid { sectionData: parent.sectionData }
|
||||
}
|
||||
Component {
|
||||
id: artistRailComp
|
||||
Column {
|
||||
property var sectionData: parent.sectionData
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 12
|
||||
Text {
|
||||
text: sectionData.title
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
ListView {
|
||||
width: parent.width
|
||||
height: 246
|
||||
orientation: ListView.Horizontal
|
||||
spacing: 32
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: sectionData.items
|
||||
delegate: ArtistCard {
|
||||
item: modelData
|
||||
artSource: modelData.artPath || ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: mixesRailComp
|
||||
MixesRail { sectionData: parent.sectionData }
|
||||
}
|
||||
Component {
|
||||
id: recentComp
|
||||
QbzEmptyState {
|
||||
property var sectionData: parent.sectionData
|
||||
title: sectionData.title
|
||||
body: sectionData.hint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component Shimmer: Rectangle {
|
||||
property bool phase: false
|
||||
color: theme.surfaceElevated
|
||||
radius: theme.radiusSm
|
||||
opacity: phase ? 0.85 : 0.4
|
||||
Behavior on opacity { NumberAnimation { duration: 900; easing.type: Easing.InOutQuad } }
|
||||
}
|
||||
component SkeletonRow: Column {
|
||||
id: skelRow
|
||||
property bool phase: false
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 12
|
||||
Shimmer { phase: skelRow.phase; width: 180; height: 22 }
|
||||
Row {
|
||||
spacing: 32
|
||||
Repeater {
|
||||
model: 5
|
||||
delegate: Column {
|
||||
spacing: 8
|
||||
Shimmer { phase: skelRow.phase; width: 200; height: 200 }
|
||||
Shimmer { phase: skelRow.phase; width: 140; height: 14 }
|
||||
Shimmer { phase: skelRow.phase; width: 90; height: 12 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ offline gate ============================
|
||||
// (OfflinePlaceholder.slint replica; mounted INSTEAD of the view.)
|
||||
QbzOfflinePlaceholder {
|
||||
visible: QbzSession.offline
|
||||
anchors.centerIn: parent
|
||||
// The induced-only "Open Settings" arm (Slint) is wired:
|
||||
showSettingsAction: true
|
||||
onSettingsClicked: QbzShell.navigateTo("settings")
|
||||
}
|
||||
|
||||
// ============================ the view ================================
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
visible: !QbzSession.offline
|
||||
|
||||
// --- Toolbar (fixed 56px) ---------------------------------------
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 56
|
||||
|
||||
Row {
|
||||
// Slint left-controls: x 32 + NavButtons (now a 0px
|
||||
// placeholder) + 16px spacing -> the pill starts at 48.
|
||||
x: 48
|
||||
y: 25 - height / 2
|
||||
spacing: 16
|
||||
|
||||
QbzTabBar {
|
||||
tabs: [
|
||||
{ "id": "home", "label": QbzSession.tr("Home", QbzSession.trRev) },
|
||||
{ "id": "editorPicks", "label": QbzSession.tr("Editor's Picks", QbzSession.trRev) },
|
||||
{ "id": "forYou", "label": QbzSession.tr("For You", QbzSession.trRev) },
|
||||
{ "id": "recommendations", "label": QbzSession.tr("Recommendations", QbzSession.trRev) },
|
||||
]
|
||||
activeId: root.activeTab
|
||||
// Data is per-tab JSON (no refetch on switch); scroll
|
||||
// resets to top.
|
||||
onSelected: function (id) {
|
||||
root.activeTab = id
|
||||
homeFlick.contentY = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Genre filter + configurator gear — INERT stubs (POC-NOTE).
|
||||
Row {
|
||||
x: parent.width - width - 32
|
||||
y: 25 - height / 2
|
||||
height: 32
|
||||
spacing: 6
|
||||
Rectangle {
|
||||
width: genreRow.width
|
||||
height: 32
|
||||
radius: 6
|
||||
color: genreArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
Row {
|
||||
id: genreRow
|
||||
height: parent.height
|
||||
leftPadding: 12
|
||||
rightPadding: 14
|
||||
spacing: 7
|
||||
QbzIcon {
|
||||
name: "list-filter"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "secondary"
|
||||
}
|
||||
Text {
|
||||
text: QbzSession.tr("Filter by genre", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: genreArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: 32
|
||||
height: 32
|
||||
radius: 4
|
||||
color: gearArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
QbzIcon {
|
||||
name: "home-gear"
|
||||
width: 20
|
||||
height: 20
|
||||
anchors.centerIn: parent
|
||||
tintName: gearArea.containsMouse ? "primary" : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: gearArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
|
||||
// --- Scrollable sections -----------------------------------------
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - 57
|
||||
Flickable {
|
||||
id: homeFlick
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: homeContent.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
Column {
|
||||
id: homeContent
|
||||
width: parent.width
|
||||
padding: 32
|
||||
spacing: 40
|
||||
|
||||
// ===== Home tab ==========================================
|
||||
Column {
|
||||
id: homeTab
|
||||
visible: root.activeTab === "home"
|
||||
width: parent.width - 64
|
||||
spacing: 40
|
||||
|
||||
// Loading skeleton (HomeSkeleton: two shimmer rows).
|
||||
Column {
|
||||
visible: QbzHome.homeLoading && root.sections.length === 0
|
||||
width: parent.width
|
||||
spacing: 40
|
||||
property bool phase: false
|
||||
Timer {
|
||||
interval: 900
|
||||
running: parent.visible
|
||||
repeat: true
|
||||
onTriggered: parent.phase = !parent.phase
|
||||
}
|
||||
SkeletonRow { phase: parent.phase }
|
||||
SkeletonRow { phase: parent.phase }
|
||||
}
|
||||
|
||||
// Error state with retry (the Slint Home has no error
|
||||
// arm; the box mirrors the FavoritesView Retry button).
|
||||
Rectangle {
|
||||
visible: QbzHome.homeError !== ""
|
||||
width: parent.width
|
||||
height: errorColumn.height + 28
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Column {
|
||||
id: errorColumn
|
||||
anchors.centerIn: parent
|
||||
spacing: 10
|
||||
Text {
|
||||
text: QbzHome.homeError
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
}
|
||||
Rectangle {
|
||||
width: retryText.implicitWidth + 28
|
||||
height: 32
|
||||
radius: 6
|
||||
color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Text {
|
||||
id: retryText
|
||||
anchors.centerIn: parent
|
||||
text: QbzSession.tr("Retry", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
MouseArea {
|
||||
id: retryArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzHome.reloadHome()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Section rails.
|
||||
SectionRails { sectionsModel: root.sections }
|
||||
}
|
||||
|
||||
// ===== Editor's Picks (phase 13) ========================
|
||||
Column {
|
||||
visible: root.activeTab === "editorPicks"
|
||||
width: parent.width - 64
|
||||
spacing: 40
|
||||
SectionRails { sectionsModel: root.editorSections }
|
||||
}
|
||||
|
||||
// ===== For You (phase 13) =================================
|
||||
Column {
|
||||
visible: root.activeTab === "forYou"
|
||||
width: parent.width - 64
|
||||
spacing: 40
|
||||
SectionRails { sectionsModel: root.forYouSections }
|
||||
}
|
||||
|
||||
// ===== Recommendations (POC-NOTE placeholder) =============
|
||||
// The tab follows the Slint gating (showRecommendations pref
|
||||
// — ON for this user, so the tab shows); the CONTENT is the
|
||||
// external reco engine (crates/qbz/src/external_reco.rs —
|
||||
// seeded similar albums, weeklies builders, dismissal
|
||||
// stores), which is not ported to the POC.
|
||||
Column {
|
||||
visible: root.activeTab === "recommendations"
|
||||
width: parent.width - 64
|
||||
spacing: 10
|
||||
Text {
|
||||
text: QbzSession.tr("Recommendations", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
Text {
|
||||
text: "QBZ Qt POC — the external recommendations engine is not ported (external_reco.rs)"
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Thin auto-hiding scrollbar in the right gutter (ListScrollbar).
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
target: homeFlick
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,603 @@
|
||||
// Playlist detail view — the QML port of playlist/PlaylistView.slint
|
||||
// (phase 17). ONE JSON document (QbzBridge.playlistJson, playlist_qt.rs
|
||||
// PlaylistDoc: header + track rows + ownership/follow/pin/sort/search).
|
||||
//
|
||||
// Header: 150px cover (server image, else first-track cover, else the
|
||||
// placeholder glyph), "PLAYLIST" eyebrow, name, description-short + Read
|
||||
// more (the shared AppShell text modal), owner • N tracks • duration,
|
||||
// the action row: Play / Shuffle / heart (owned, wired) / pin (wired) /
|
||||
// follow (foreign, subscribe API) / copy (foreign, create+add) / edit
|
||||
// (owner: rename + delete modal). Right edge: in-playlist search + the
|
||||
// sort dropdown (Default/Title/Artist/Album/Duration/Date added/Custom).
|
||||
//
|
||||
// Track list: the exact PlaylistView row (# / 36px art / title+artist /
|
||||
// Album 220px link / Duration 70 / Quality 92 / heart / cloud reserve /
|
||||
// ⋯ menu with Remove-from-playlist for owners), column header, empty and
|
||||
// loading states. Per-row ⋯: Play / Play next / Play later / Add to
|
||||
// queue / Go to artist / Go to album / Add|Remove from Library / Remove
|
||||
// from playlist (owner).
|
||||
//
|
||||
// Drag & drop (issue #589): the row BODY is the drag source (press-drag
|
||||
// >6px — the same gesture that drops onto sidebar playlists). A release
|
||||
// INSIDE this list reorders (owner, switches the sort to Custom and
|
||||
// persists the order); a release on a sidebar playlist row is the shared
|
||||
// add-to-playlist drop (main.rs drag_end). The 2px accent line marks the
|
||||
// insertion slot while dragging.
|
||||
//
|
||||
// POC-NOTEs (playlist_qt.rs has the full list): local playlists,
|
||||
// custom-cover set/clear, multi-select + bulk bar, Suggested Songs,
|
||||
// offline download, share — not ported.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import com.blitzfc.qbz
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
radius: 12
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
readonly property var doc: parseDoc()
|
||||
function parseDoc() {
|
||||
try {
|
||||
return JSON.parse(QbzBridge.playlistJson)
|
||||
} catch (e) {
|
||||
return ({})
|
||||
}
|
||||
}
|
||||
readonly property var allTracks: doc.tracks || []
|
||||
readonly property bool isOwner: doc.isOwner === true
|
||||
readonly property bool loading: doc.loading === true
|
||||
readonly property string sortField: doc.sortField || "default"
|
||||
readonly property bool sortAsc: doc.sortAsc === true
|
||||
readonly property string searchQuery: doc.search || ""
|
||||
|
||||
// Visible rows after the in-playlist search filter.
|
||||
readonly property var tracks: {
|
||||
if (searchQuery.trim() === "") return allTracks
|
||||
const needle = searchQuery.trim().toLowerCase()
|
||||
return allTracks.filter(function (t) {
|
||||
return (t.title || "").toLowerCase().indexOf(needle) >= 0
|
||||
|| (t.artist || "").toLowerCase().indexOf(needle) >= 0
|
||||
|| (t.album || "").toLowerCase().indexOf(needle) >= 0
|
||||
})
|
||||
}
|
||||
|
||||
function sortLabel() {
|
||||
return sortField === "title" ? QbzSession.tr("Title", QbzSession.trRev)
|
||||
: sortField === "artist" ? QbzSession.tr("Artist", QbzSession.trRev)
|
||||
: sortField === "album" ? QbzSession.tr("Album", QbzSession.trRev)
|
||||
: sortField === "duration" ? QbzSession.tr("Duration", QbzSession.trRev)
|
||||
: sortField === "added" ? QbzSession.tr("Date added", QbzSession.trRev)
|
||||
: sortField === "custom" ? QbzSession.tr("Custom", QbzSession.trRev)
|
||||
: QbzSession.tr("Default", QbzSession.trRev)
|
||||
}
|
||||
|
||||
// --- Reorder state (the view-level half of the shared drag) ----------
|
||||
property int reorderFrom: -1
|
||||
property int reorderOver: -1
|
||||
property string reorderDropPlaylist: ""
|
||||
|
||||
// The guarded mirror (PlaylistView.slint: Rust clears over-playlist-id
|
||||
// in the same turn it clears active, so the value must be captured
|
||||
// WHILE the drag is live).
|
||||
Connections {
|
||||
target: QbzShell
|
||||
function onDragOverPlaylistIdChanged() {
|
||||
if (QbzShell.dragActive) root.reorderDropPlaylist = QbzShell.dragOverPlaylistId
|
||||
}
|
||||
function onDragXChanged() { root.updateSlot() }
|
||||
function onDragYChanged() { root.updateSlot() }
|
||||
function onDragActiveChanged() {
|
||||
if (QbzShell.dragActive) return
|
||||
// Drag ended (main.rs drag_end already ran — it handled a
|
||||
// sidebar-playlist drop, if any). A release INSIDE the list and
|
||||
// NOT on a sidebar playlist reorders.
|
||||
if (root.reorderFrom >= 0
|
||||
&& root.reorderDropPlaylist === ""
|
||||
&& root.pointerInList()
|
||||
&& root.slotFromPointer() !== root.reorderFrom
|
||||
&& root.slotFromPointer() !== root.reorderFrom + 1) {
|
||||
QbzBridge.playlistReorder(root.reorderFrom, root.slotFromPointer())
|
||||
}
|
||||
root.reorderFrom = -1
|
||||
root.reorderOver = -1
|
||||
root.reorderDropPlaylist = ""
|
||||
}
|
||||
}
|
||||
function pointerInList() {
|
||||
if (!trackList) return false
|
||||
const tl = trackList.mapToItem(null, 0, 0)
|
||||
const br = trackList.mapToItem(null, trackList.width, trackList.height)
|
||||
return QbzShell.dragX >= tl.x && QbzShell.dragX <= br.x
|
||||
&& QbzShell.dragY >= tl.y && QbzShell.dragY <= br.y
|
||||
}
|
||||
function slotFromPointer() {
|
||||
const tl = trackList.mapToItem(null, 0, 0)
|
||||
return Math.max(0, Math.min(tracks.length,
|
||||
Math.round((QbzShell.dragY - tl.y + trackList.contentY) / 50)))
|
||||
}
|
||||
function updateSlot() {
|
||||
if (root.reorderFrom >= 0 && QbzShell.dragActive) {
|
||||
root.reorderOver = root.pointerInList() ? root.slotFromPointer() : -1
|
||||
}
|
||||
}
|
||||
|
||||
// Read more → the shared AppShell text modal (phase 16 pattern).
|
||||
function openDescription() {
|
||||
var shell = root.parent
|
||||
while (shell && shell.openTextModal === undefined) shell = shell.parent
|
||||
if (shell) shell.openTextModal(doc.name || "", doc.description || "")
|
||||
}
|
||||
|
||||
// --- Circular header action (CircleAction, on-surface variant) -------
|
||||
|
||||
|
||||
// ============================ the view ================================
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 32
|
||||
anchors.rightMargin: 16
|
||||
anchors.topMargin: 11
|
||||
anchors.bottomMargin: 16
|
||||
spacing: 0
|
||||
|
||||
Item { width: 1; height: 22 }
|
||||
|
||||
// --- Header ---------------------------------------------------------
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 24
|
||||
|
||||
// Cover.
|
||||
Rectangle {
|
||||
width: 150
|
||||
height: 150
|
||||
radius: theme.radiusMd
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
RoundedImage {
|
||||
visible: (doc.coverPath || "") !== ""
|
||||
anchors.fill: parent
|
||||
source: doc.coverPath || ""
|
||||
radius: theme.radiusMd
|
||||
}
|
||||
QbzIcon {
|
||||
visible: (doc.coverPath || "") === ""
|
||||
name: "list-music"
|
||||
width: 56
|
||||
height: 56
|
||||
anchors.centerIn: parent
|
||||
tintName: "muted"
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata.
|
||||
Column {
|
||||
width: parent.width - 150 - 24
|
||||
spacing: 0
|
||||
Text {
|
||||
text: QbzSession.tr("Playlist", QbzSession.trRev).toUpperCase()
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 1.5
|
||||
}
|
||||
Item { width: 1; height: 4 }
|
||||
Text {
|
||||
width: parent.width
|
||||
text: doc.name || ""
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightBold
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
Item { visible: (doc.descriptionShort || "") !== ""; width: 1; height: 6 }
|
||||
Text {
|
||||
visible: (doc.descriptionShort || "") !== ""
|
||||
width: Math.min(parent.width, 700)
|
||||
text: doc.descriptionShort || ""
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
Text {
|
||||
visible: (doc.description || "") !== (doc.descriptionShort || "")
|
||||
height: 18
|
||||
text: QbzSession.tr("Read more", QbzSession.trRev)
|
||||
color: rmArea.containsMouse ? theme.accentHover : theme.accent
|
||||
font.pixelSize: theme.fontLegal
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
MouseArea {
|
||||
id: rmArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.openDescription()
|
||||
}
|
||||
}
|
||||
Item { width: 1; height: 8 }
|
||||
Text {
|
||||
text: (doc.owner || "") + " • " + (doc.trackCount || 0) + " " + QbzSession.tr("tracks", QbzSession.trRev)
|
||||
+ " • " + (doc.totalDuration || "")
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
Item { width: 1; height: 14 }
|
||||
// Actions — buttons left, search + sort floating right.
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
QbzCircleAction {
|
||||
name: "play-fill"
|
||||
primary: true
|
||||
btnEnabled: root.allTracks.length > 0
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzBridge.playlistPlayAll()
|
||||
}
|
||||
QbzCircleAction {
|
||||
name: "shuffle"
|
||||
btnEnabled: root.allTracks.length > 0
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzBridge.playlistShuffle()
|
||||
}
|
||||
QbzCircleAction {
|
||||
name: (doc.isFavorite === true) ? "heart-filled" : "heart"
|
||||
active: doc.isFavorite === true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzBridge.playlistToggleFavorite()
|
||||
}
|
||||
QbzCircleAction {
|
||||
name: (doc.pinned === true) ? "pin-filled" : "pin"
|
||||
active: doc.pinned === true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzBridge.playlistTogglePin()
|
||||
}
|
||||
QbzCircleAction {
|
||||
visible: !root.isOwner
|
||||
name: (doc.isFollowing === true) ? "check" : "user-plus"
|
||||
active: doc.isFollowing === true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzBridge.playlistToggleFollow()
|
||||
}
|
||||
QbzCircleAction {
|
||||
visible: !root.isOwner && doc.isCopied !== true
|
||||
name: "copy"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzBridge.playlistCopy()
|
||||
}
|
||||
QbzCircleAction {
|
||||
visible: root.isOwner
|
||||
name: "pen-line"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: editModal.open()
|
||||
}
|
||||
|
||||
Item { width: parent.width - 40 - 32 * 6 - 7 * 12 - 220 - 140; height: 1 }
|
||||
|
||||
// In-playlist search.
|
||||
Rectangle {
|
||||
width: 220
|
||||
height: 30
|
||||
radius: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 9
|
||||
anchors.rightMargin: 9
|
||||
spacing: 6
|
||||
QbzIcon { name: "search"; width: 13; height: 13; anchors.verticalCenter: parent.verticalCenter; tintName: "muted" }
|
||||
TextInput {
|
||||
width: parent.width - 19
|
||||
height: parent.height
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
clip: true
|
||||
onTextEdited: QbzBridge.playlistSetSearch(text)
|
||||
Text {
|
||||
visible: parent.text === ""
|
||||
anchors.fill: parent
|
||||
text: QbzSession.tr("Search tracks", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort dropdown.
|
||||
Rectangle {
|
||||
width: 132
|
||||
height: 34
|
||||
radius: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: sortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 11
|
||||
anchors.rightMargin: 8
|
||||
spacing: 5
|
||||
Text {
|
||||
width: parent.width - 13 - 5
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Sort", QbzSession.trRev) + ": " + root.sortLabel()
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
QbzIcon { name: "chevron-down"; width: 13; height: 13; anchors.verticalCenter: parent.verticalCenter; tintName: "muted" }
|
||||
}
|
||||
MouseArea {
|
||||
id: sortArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: sortMenu.openBelowRight(sortArea)
|
||||
}
|
||||
QbzContextMenu {
|
||||
id: sortMenu
|
||||
menuWidth: 172
|
||||
Repeater {
|
||||
model: [
|
||||
{ "field": "default", "label": QbzSession.tr("Default", QbzSession.trRev) },
|
||||
{ "field": "title", "label": QbzSession.tr("Title", QbzSession.trRev) },
|
||||
{ "field": "artist", "label": QbzSession.tr("Artist", QbzSession.trRev) },
|
||||
{ "field": "album", "label": QbzSession.tr("Album", QbzSession.trRev) },
|
||||
{ "field": "duration", "label": QbzSession.tr("Duration", QbzSession.trRev) },
|
||||
{ "field": "added", "label": QbzSession.tr("Date added", QbzSession.trRev) },
|
||||
{ "field": "custom", "label": QbzSession.tr("Custom", QbzSession.trRev), "ownerOnly": true },
|
||||
]
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
visible: modelData.ownerOnly !== true || root.isOwner
|
||||
width: parent ? parent.width : 0
|
||||
height: visible ? 33 : 0
|
||||
radius: 5
|
||||
color: soArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
spacing: 6
|
||||
Text {
|
||||
width: parent.width - 26
|
||||
height: parent.height
|
||||
text: modelData.label
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
font.weight: root.sortField === modelData.field ? theme.weightSemibold : theme.weightRegular
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
QbzIcon {
|
||||
visible: root.sortField === modelData.field
|
||||
name: root.sortAsc ? "chevron-up" : "chevron-down"
|
||||
width: 12
|
||||
height: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "accent"
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: soArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
sortMenu.close()
|
||||
QbzBridge.playlistSetSort(modelData.field)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { width: 1; height: 18 }
|
||||
|
||||
// Loading / empty.
|
||||
QbzSpinner {
|
||||
visible: root.loading
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
size: 36
|
||||
}
|
||||
Text {
|
||||
visible: !root.loading && root.allTracks.length === 0
|
||||
text: QbzSession.tr("This playlist is empty.", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontBody
|
||||
}
|
||||
|
||||
// --- Column header ---------------------------------------------------
|
||||
Row {
|
||||
visible: root.tracks.length > 0
|
||||
width: parent.width
|
||||
anchors.leftMargin: 12
|
||||
spacing: 14
|
||||
Text { text: "#"; width: 32; color: theme.textMuted; font.pixelSize: theme.fontLegal; horizontalAlignment: Text.AlignHCenter }
|
||||
Rectangle { width: 36; height: 1; color: "transparent" }
|
||||
Text { text: QbzSession.tr("Title", QbzSession.trRev); width: parent.width - 32 - 36 - 220 - 70 - 92 - 28 - 28 - 32 - 8 * 14; color: theme.textMuted; font.pixelSize: theme.fontLegal }
|
||||
Text { text: QbzSession.tr("Album", QbzSession.trRev); width: 220; color: theme.textMuted; font.pixelSize: theme.fontLegal }
|
||||
Text { text: QbzSession.tr("Duration", QbzSession.trRev); width: 70; color: theme.textMuted; font.pixelSize: theme.fontLegal; horizontalAlignment: Text.AlignHCenter }
|
||||
Text { text: QbzSession.tr("Quality", QbzSession.trRev); width: 92; color: theme.textMuted; font.pixelSize: theme.fontLegal; horizontalAlignment: Text.AlignHCenter }
|
||||
Rectangle { width: 28; height: 1; color: "transparent" }
|
||||
Rectangle { width: 28; height: 1; color: "transparent" }
|
||||
Rectangle { width: 32; height: 1; color: "transparent" }
|
||||
}
|
||||
Rectangle { visible: root.tracks.length > 0; width: 1; height: 3; color: "transparent" }
|
||||
Rectangle { visible: root.tracks.length > 0; width: parent.width; height: 1; color: theme.borderSubtle }
|
||||
Item { width: 1; height: 6 }
|
||||
|
||||
// --- Track list -------------------------------------------------------
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - 28 - 150 - 18 - 50
|
||||
|
||||
ListView {
|
||||
id: trackList
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: 14
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
cacheBuffer: 500
|
||||
model: root.tracks
|
||||
delegate: TrackRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent ? parent.width : 0
|
||||
item: modelData
|
||||
number: index + 1
|
||||
showArtwork: true
|
||||
showAlbum: true
|
||||
showDownload: true
|
||||
menuShowRemove: root.isOwner
|
||||
onPlayRequested: QbzBridge.playlistPlayTrack(item.id)
|
||||
onEnqueueRequested: function (m) { QbzBridge.playlistEnqueueTrack(item.id, m) }
|
||||
onRemoveRequested: QbzBridge.playlistRemoveTrack(item.playlistTrackId)
|
||||
onBodyDragStarted: function (n) {
|
||||
// #589: report the source index BEFORE the shared drag.
|
||||
if (root.isOwner) {
|
||||
root.reorderFrom = index
|
||||
root.reorderOver = -1
|
||||
root.reorderDropPlaylist = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop indicator: a 2px accent line at the insertion slot,
|
||||
// hidden on the no-op slots and while outside the list.
|
||||
Rectangle {
|
||||
visible: root.reorderFrom >= 0
|
||||
&& root.reorderOver >= 0
|
||||
&& root.reorderOver !== root.reorderFrom
|
||||
&& root.reorderOver !== root.reorderFrom + 1
|
||||
x: 0
|
||||
y: Math.max(0, Math.min(parent.height - 2,
|
||||
root.reorderOver * 50 - trackList.contentY - 1))
|
||||
width: parent.width - 14
|
||||
height: 2
|
||||
radius: 1
|
||||
color: theme.accent
|
||||
}
|
||||
|
||||
QbzScrollBar {
|
||||
target: trackList
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edit modal (rename + delete; owner) ---------------------------------
|
||||
Popup {
|
||||
id: editModal
|
||||
parent: Overlay.overlay
|
||||
anchors.centerIn: parent
|
||||
width: 380
|
||||
padding: 20
|
||||
closePolicy: Popup.CloseOnEscape
|
||||
background: Rectangle {
|
||||
color: theme.surfaceCard
|
||||
radius: theme.radiusMd
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
}
|
||||
contentItem: Column {
|
||||
spacing: 14
|
||||
Text {
|
||||
text: QbzSession.tr("Edit playlist", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 36
|
||||
radius: 6
|
||||
color: theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
TextInput {
|
||||
id: nameInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
text: root.doc.name || ""
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 14
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
clip: true
|
||||
}
|
||||
}
|
||||
Row {
|
||||
spacing: 10
|
||||
anchors.right: parent.right
|
||||
Rectangle {
|
||||
width: delText.implicitWidth + 28
|
||||
height: 34
|
||||
radius: 6
|
||||
color: delArea.containsMouse ? "#33ef4444" : "transparent"
|
||||
border.width: 1
|
||||
border.color: "#66ef4444"
|
||||
Text {
|
||||
id: delText
|
||||
anchors.centerIn: parent
|
||||
text: QbzSession.tr("Delete", QbzSession.trRev)
|
||||
color: "#ef4444"
|
||||
font.pixelSize: 13
|
||||
font.weight: theme.weightMedium
|
||||
}
|
||||
MouseArea {
|
||||
id: delArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
editModal.close()
|
||||
QbzBridge.playlistDelete()
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: saveText.implicitWidth + 28
|
||||
height: 34
|
||||
radius: 6
|
||||
color: saveArea.containsMouse ? theme.accentHover : theme.accent
|
||||
Text {
|
||||
id: saveText
|
||||
anchors.centerIn: parent
|
||||
text: QbzSession.tr("Save", QbzSession.trRev)
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 13
|
||||
font.weight: theme.weightMedium
|
||||
}
|
||||
MouseArea {
|
||||
id: saveArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
editModal.close()
|
||||
QbzBridge.playlistRename(nameInput.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
// 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:
|
||||
// - Card click actions: albums/artists navigate; tracks play; playlists
|
||||
// open (openPlaylist is wired; the stale "inert" note was dropped in
|
||||
// phase 21, when every card surface moved to the shared qml/ cards).
|
||||
// - The most-popular track hero stays a DISTINCT component
|
||||
// (SearchTrackHero below): in Slint the search hero is
|
||||
// primitives/SearchTrackHero.slint, NOT discover/TrackCard.slint —
|
||||
// the POC variant is 200x246 (centered play, quality as text) to match
|
||||
// the ArtistGridCard hero slot it shares the row with.
|
||||
// - 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
|
||||
import "../cards"
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.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
|
||||
|
||||
// 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: QbzPlayer.playTrack(card.id)
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: heroArtArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzPlayer.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: QbzSession.tr("Track", QbzSession.trRev) + " • " + (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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track row (primitives/TrackRow.slint, 50px — number/play, title +
|
||||
// explicit, artist, duration, quality, ⋯ menu).
|
||||
// "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: QbzSession.tr("Load more", QbzSession.trRev) + " (" + 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: QbzSession.tr("Search", QbzSession.trRev)
|
||||
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: QbzSession.tr("Main Artist", QbzSession.trRev); selected: root.filterIndex === 1; onPicked: QbzBridge.searchFilterChanged(1) }
|
||||
FilterRadio { label: QbzSession.tr("Performer", QbzSession.trRev); selected: root.filterIndex === 2; onPicked: QbzBridge.searchFilterChanged(2) }
|
||||
FilterRadio { label: QbzSession.tr("Composer", QbzSession.trRev); selected: root.filterIndex === 3; onPicked: QbzBridge.searchFilterChanged(3) }
|
||||
FilterRadio { label: QbzSession.tr("Label", QbzSession.trRev); selected: root.filterIndex === 4; onPicked: QbzBridge.searchFilterChanged(4) }
|
||||
FilterRadio { label: QbzSession.tr("Release Name", QbzSession.trRev); 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": QbzSession.tr("All", QbzSession.trRev) },
|
||||
{ "t": 1, "label": QbzSession.tr("Albums", QbzSession.trRev) },
|
||||
{ "t": 2, "label": QbzSession.tr("Tracks", QbzSession.trRev) },
|
||||
{ "t": 3, "label": QbzSession.tr("Artists", QbzSession.trRev) },
|
||||
{ "t": 4, "label": QbzSession.tr("Playlists", QbzSession.trRev) },
|
||||
]
|
||||
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: QbzSession.tr("Most popular", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
}
|
||||
Item {
|
||||
width: 200
|
||||
height: 246
|
||||
ArtistCard {
|
||||
visible: root.mp.kind === "artist"
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
item: root.mp.artist || ({})
|
||||
artSource: (root.mp.artist || ({})).artPath || ""
|
||||
}
|
||||
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
|
||||
QbzSectionHeader {
|
||||
title: QbzSession.tr("Artists", QbzSession.trRev)
|
||||
showViewAll: true
|
||||
viewAllAccent: true
|
||||
showChevrons: false
|
||||
onViewAllClicked: QbzBridge.searchTabChanged(3)
|
||||
}
|
||||
ListView {
|
||||
width: parent.width
|
||||
height: 246
|
||||
orientation: ListView.Horizontal
|
||||
spacing: 32
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.artistsCarousel
|
||||
delegate: ArtistCard {
|
||||
item: modelData
|
||||
artSource: modelData.artPath || ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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
|
||||
QbzSectionHeader {
|
||||
title: QbzSession.tr("Artists", QbzSession.trRev)
|
||||
showViewAll: true
|
||||
viewAllAccent: true
|
||||
showChevrons: false
|
||||
onViewAllClicked: QbzBridge.searchTabChanged(3)
|
||||
}
|
||||
ListView {
|
||||
width: parent.width
|
||||
height: 246
|
||||
orientation: ListView.Horizontal
|
||||
spacing: 32
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.artists
|
||||
delegate: ArtistCard {
|
||||
item: modelData
|
||||
artSource: modelData.artPath || ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Albums --------------------------------------------
|
||||
Column {
|
||||
visible: root.tab === 0 && root.albums.length > 0
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
QbzSectionHeader {
|
||||
title: QbzSession.tr("Albums", QbzSession.trRev)
|
||||
showViewAll: true
|
||||
viewAllAccent: true
|
||||
showChevrons: false
|
||||
onViewAllClicked: 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
|
||||
QbzSectionHeader {
|
||||
title: QbzSession.tr("Tracks", QbzSession.trRev)
|
||||
showViewAll: root.tab === 0 && (root.doc.tracksTotal || 0) > root.previewCap
|
||||
viewAllAccent: true
|
||||
showChevrons: false
|
||||
onViewAllClicked: QbzBridge.searchTabChanged(2)
|
||||
}
|
||||
Repeater {
|
||||
model: root.tab === 2 ? root.tracks : root.tracks.slice(0, root.previewCap)
|
||||
delegate: TrackRow {
|
||||
item: modelData
|
||||
number: index + 1
|
||||
menuShowFavorite: false
|
||||
onPlayRequested: QbzPlayer.playTrack(item.id)
|
||||
onEnqueueRequested: function (m) { QbzPlayer.enqueueTrack(item.id, m) }
|
||||
}
|
||||
}
|
||||
}
|
||||
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) / 216))) * 262 - 16)
|
||||
cellWidth: 216
|
||||
cellHeight: 262
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
interactive: false
|
||||
model: root.artists
|
||||
delegate: ArtistCard {
|
||||
item: modelData
|
||||
artSource: modelData.artPath || ""
|
||||
}
|
||||
}
|
||||
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
|
||||
QbzSectionHeader {
|
||||
title: QbzSession.tr("Playlists", QbzSession.trRev)
|
||||
showViewAll: true
|
||||
viewAllAccent: true
|
||||
showChevrons: false
|
||||
onViewAllClicked: QbzBridge.searchTabChanged(4)
|
||||
}
|
||||
ListView {
|
||||
width: parent.width
|
||||
height: 246
|
||||
orientation: ListView.Horizontal
|
||||
spacing: 32
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.playlists
|
||||
delegate: PlaylistCard {
|
||||
item: modelData
|
||||
artSource: modelData.artPath || ""
|
||||
}
|
||||
}
|
||||
}
|
||||
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: PlaylistCard {
|
||||
item: modelData
|
||||
artSource: modelData.artPath || ""
|
||||
}
|
||||
}
|
||||
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: QbzSession.tr("Searching…", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
}
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: !root.loading && !root.hasResults
|
||||
x: 32
|
||||
y: 8
|
||||
text: QbzSession.tr("No results.", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 14
|
||||
}
|
||||
}
|
||||
QbzScrollBar {
|
||||
target: bodyFlick
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Horizontal album rail (Carousel.slint) — extracted for reuse by the
|
||||
// detail views (AlbumView / ArtistView): section header + page chevrons +
|
||||
// clipped ListView of the shared AlbumCard, with Cider-style edge fades.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
|
||||
Column {
|
||||
id: root
|
||||
property string title: ""
|
||||
property var items: []
|
||||
// The host's url-keyed cover map ({url: file://path}).
|
||||
property var coverMap: ({})
|
||||
|
||||
QbzTheme { id: theme }
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 12
|
||||
|
||||
readonly property int perPage: Math.max(1, Math.floor((rail.width + 32) / 232))
|
||||
readonly property int step: perPage * 232
|
||||
readonly property real maxScroll: Math.max(0, rail.contentWidth - rail.width)
|
||||
|
||||
|
||||
|
||||
QbzSectionHeader {
|
||||
title: root.title
|
||||
leftEnabled: rail.contentX > 1
|
||||
rightEnabled: rail.contentX < root.maxScroll - 1
|
||||
onPageLeft: rail.contentX = Math.max(0, rail.contentX - root.step)
|
||||
onPageRight: rail.contentX = Math.min(root.maxScroll, rail.contentX + root.step)
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 246
|
||||
ListView {
|
||||
id: rail
|
||||
anchors.fill: parent
|
||||
orientation: ListView.Horizontal
|
||||
spacing: 32
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.items
|
||||
delegate: AlbumCard {
|
||||
albumId: modelData.id
|
||||
title: modelData.title
|
||||
artist: modelData.artist
|
||||
artistId: modelData.artistId
|
||||
genre: modelData.genre
|
||||
year: modelData.year
|
||||
qualityTier: modelData.qualityTier
|
||||
artSource: root.coverMap[modelData.artUrl] || ""
|
||||
isFavorite: false
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: 56
|
||||
opacity: rail.contentX > 1 ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: theme.surfaceMain }
|
||||
GradientStop { position: 1.0; color: "transparent" }
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: 56
|
||||
opacity: rail.contentX < root.maxScroll - 1 ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: "transparent" }
|
||||
GradientStop { position: 1.0; color: theme.surfaceMain }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user