Buttons that rest on surfaceElevated used surfaceHover for their hover fill: a 6% white wash meant for rows that rest transparent. Over an elevated button it lands DARKER than the rest fill — on a modal's dark ground the Cancel/Save/footer buttons went near-black under the pointer. QbzTheme gains surfaceElevatedHover (a lift of surfaceElevated, derived so every palette has it), its ambient half-alpha twin, and the elevatedFill / elevatedHoverFill pair an ambient-aware control paints. Every site with the old pairing (65 across modals, toolbars, selects, circle actions, chips and cards) now hovers to the lift. cc
904 lines
43 KiB
QML
904 lines
43 KiB
QML
// "Add to playlist" picker — QML port of
|
|
// crates/qbz-ui/ui/primitives/PlaylistPickerModal.slint (+ its
|
|
// PlaylistDuplicateConfirmModal sibling, folded in here because the two
|
|
// share one document and one carried track set).
|
|
//
|
|
// A focus-opened dropdown lists the user's playlists; clicking a row only
|
|
// SELECTS it (the selection becomes the input's placeholder); the footer
|
|
// button fires the actual add. "+ Create new playlist" is always the first
|
|
// dropdown row and reveals an inline name field confirmed by the same
|
|
// footer button ("Create & Add").
|
|
//
|
|
// Mounted ONCE in AppShell next to AddToMixtapeModal: every host that opens
|
|
// it (the queue footer and row menu today) can be unmounted by the time the
|
|
// user picks a target, so the modal must outlive them.
|
|
//
|
|
// Data: QbzPlaylistPicker.pickerJson (playlist_picker_qt.rs PickerDoc). The
|
|
// name filter runs in RUST (the QbzMyQbzAdd.search convention), so the row
|
|
// list arrives already filtered; the 8-row window and the overflow line are
|
|
// applied here against `total`/`playlists.length`.
|
|
//
|
|
// LOCAL playlists are listed alongside the Qobuz ones (a hard-drive glyph
|
|
// leads the row, `isLocal` in the document) and are the ONLY rows while
|
|
// offline — a Qobuz playlist cannot be written to without a session, so
|
|
// offering one would be a dead target (D3/D11). The carried payload is
|
|
// likewise either Qobuz catalog ids or LocalLibrary refs; `localMode` only
|
|
// changes the counter's wording ("Adding {} local track(s)"), never the row
|
|
// list — a local selection can legitimately target a Qobuz playlist, which
|
|
// Rust routes through the library.db sidecar.
|
|
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import com.blitzfc.qbz
|
|
import "../theme"
|
|
|
|
Item {
|
|
id: root
|
|
// An unopened overlay is an invisible, non-interactive Item and costs
|
|
// nothing (the AppShell mount comment states the contract).
|
|
visible: doc.open === true || dup.open === true
|
|
|
|
readonly property var doc: JSON.parse(QbzPlaylistPicker.pickerJson)
|
|
readonly property var rows: doc.playlists || []
|
|
readonly property var dup: doc.dup || ({})
|
|
readonly property int trackCount: doc.trackCount || 0
|
|
// Session-local pinned target (last successful add); null when unset,
|
|
// deleted, unwritable, or filtered out by the current query.
|
|
readonly property var lastUsed: doc.lastUsed || null
|
|
// Containment strip: writable playlists already holding carried tracks,
|
|
// honest per alreadyState ("loading"|"complete"|"updating"|"unavailable").
|
|
readonly property var alreadyIn: doc.alreadyIn || []
|
|
readonly property string alreadyState: doc.alreadyState || ""
|
|
// The column renders only when it can back a claim: complete with
|
|
// matches, or updating (where partial matches show under an explicit
|
|
// caveat). It is a SIDE column, not an in-flow strip, so the dropdown
|
|
// can never cover it.
|
|
readonly property bool alreadyShowing:
|
|
root.alreadyState === "updating"
|
|
|| (root.alreadyState === "complete" && root.alreadyIn.length > 0)
|
|
// The carried payload is LocalLibrary refs (counter wording only).
|
|
readonly property bool localMode: doc.localMode === true
|
|
readonly property bool creatingOpen: doc.creatingOpen === true
|
|
readonly property bool creating: doc.creating === true
|
|
|
|
// Rows past the 8-row window collapse into the "{} more playlist(s)..."
|
|
// line, exactly as the .slint's filter-rank window does.
|
|
readonly property int windowSize: 8
|
|
readonly property int shown: Math.min(rows.length, windowSize)
|
|
readonly property int overflow: Math.max(0, rows.length - windowSize)
|
|
|
|
// --- Local selection state (the .slint keeps these on the `if open:`
|
|
// subtree so a remount resets them; here they are reset when `open`
|
|
// flips, which is the same lifetime without a conditional Loader). ---
|
|
property bool dropdownOpen: false
|
|
property string selectedId: ""
|
|
property string selectedLabel: ""
|
|
property string filterText: ""
|
|
property string createName: ""
|
|
|
|
onVisibleChanged: if (!visible) resetLocals()
|
|
function resetLocals() {
|
|
dropdownOpen = false
|
|
selectedId = ""
|
|
selectedLabel = ""
|
|
filterText = ""
|
|
createName = ""
|
|
}
|
|
|
|
function selectRow(row) {
|
|
selectedId = row.id
|
|
selectedLabel = (row.tracksLine || "") === ""
|
|
? row.name : row.name + " (" + row.tracksLine + ")"
|
|
// The reference clears the query on selection (Tauri §2.4).
|
|
filterText = ""
|
|
QbzPlaylistPicker.search("")
|
|
QbzPlaylistPicker.showCreate(false)
|
|
dropdownOpen = false
|
|
}
|
|
|
|
function confirm() {
|
|
if (root.creating)
|
|
return
|
|
if (root.creatingOpen) {
|
|
if (createName.trim() !== "")
|
|
QbzPlaylistPicker.createAndAdd(createName)
|
|
} else if (selectedId !== "") {
|
|
QbzPlaylistPicker.pick(selectedId)
|
|
}
|
|
}
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
// ======================= The picker ==================================
|
|
Item {
|
|
anchors.fill: parent
|
|
visible: root.doc.open === true && root.dup.open !== true
|
|
|
|
// Scrim — click outside dismisses. The wheel is eaten too: these
|
|
// overlays are plain Items, not Popups, so no modal grab stops the
|
|
// page underneath from scrolling (the DiscoverConfigModal contract).
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: "#bf000000"
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: QbzPlaylistPicker.close()
|
|
onWheel: function (wheel) { wheel.accepted = true }
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: card
|
|
anchors.centerIn: parent
|
|
width: Math.min(parent.width - 80, 735)
|
|
// Tauri: min-height 400 / max-height 90vh. The body stretches so
|
|
// the footer pins to the bottom.
|
|
height: Math.min(Math.max(400, panel.implicitHeight), parent.height * 0.9)
|
|
radius: theme.radiusLg
|
|
color: theme.surfaceCard
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
// NO clip: the dropdown legitimately overflows the card bottom.
|
|
|
|
// The "Already in" side column's slot. Zero when absent, so the
|
|
// main content takes the whole card.
|
|
readonly property real rightPanelW: root.alreadyShowing ? 221 : 0
|
|
|
|
// Swallow clicks so they never reach the scrim; doubles as
|
|
// "a click inside the card closes the list" (Tauri parity).
|
|
// The wheel stops here too — same reason as the scrim.
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: root.dropdownOpen = false
|
|
onWheel: function (wheel) { wheel.accepted = true }
|
|
}
|
|
|
|
Column {
|
|
id: panel
|
|
width: parent.width
|
|
|
|
// --- Header -------------------------------------------
|
|
Item {
|
|
width: parent.width
|
|
height: 68
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 24
|
|
anchors.right: closeX.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzSession.tr("Add to Playlist", QbzSession.trRev)
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontSection
|
|
font.weight: theme.weightSemibold
|
|
elide: Text.ElideRight
|
|
}
|
|
Item {
|
|
id: closeX
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 24
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 28
|
|
height: 28
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
name: "x"
|
|
width: 17
|
|
height: 17
|
|
tintName: closeArea.containsMouse ? "textPrimary" : "muted"
|
|
}
|
|
MouseArea {
|
|
id: closeArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: QbzPlaylistPicker.close()
|
|
}
|
|
}
|
|
}
|
|
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
|
|
|
|
// --- Body (the MAIN column; the "Already in" side column
|
|
// takes the right slot when present) -------------------
|
|
Column {
|
|
id: body
|
|
width: parent.width - card.rightPanelW
|
|
padding: 24
|
|
spacing: 16
|
|
|
|
// Loading — the playlists are still on the wire.
|
|
Item {
|
|
width: parent.width - 48
|
|
height: visible ? 30 : 0
|
|
visible: root.doc.loading === true
|
|
QbzSpinner {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
size: 30
|
|
}
|
|
}
|
|
|
|
Column {
|
|
width: parent.width - 48
|
|
spacing: 8
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: 20
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzSession.tr("Choose playlist", QbzSession.trRev)
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontLegal
|
|
font.weight: theme.weightMedium
|
|
}
|
|
// Two msgids, not one with an inserted word: the
|
|
// .slint counter switches the whole string on
|
|
// `local-mode` so every locale can order it its
|
|
// own way.
|
|
Text {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: (root.localMode
|
|
? QbzSession.tr("Adding {} local track(s)", QbzSession.trRev)
|
|
: QbzSession.tr("Adding {} track(s)", QbzSession.trRev))
|
|
.replace("{}", root.trackCount)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLink
|
|
}
|
|
}
|
|
|
|
// The search / selection input (43px, the Tauri metric).
|
|
Rectangle {
|
|
id: inputBox
|
|
width: parent.width
|
|
height: 43
|
|
radius: theme.radiusSm
|
|
color: theme.surfaceCard
|
|
border.width: 1
|
|
border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle
|
|
|
|
TextInput {
|
|
|
|
QbzTextEditMenu { }
|
|
id: searchInput
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 12
|
|
anchors.rightMargin: 36 // room for the clear chip
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontLink
|
|
verticalAlignment: Text.AlignVCenter
|
|
clip: true
|
|
selectByMouse: true
|
|
enabled: root.doc.loading !== true
|
|
text: root.filterText
|
|
onTextEdited: {
|
|
root.filterText = text
|
|
root.dropdownOpen = true // typing re-opens
|
|
QbzPlaylistPicker.search(text)
|
|
}
|
|
onAccepted: root.confirm()
|
|
// Tauri: the list opens on focus.
|
|
onActiveFocusChanged: if (activeFocus) root.dropdownOpen = true
|
|
// Re-seed while not being edited (the
|
|
// AddToMixtapeModal Binding shape).
|
|
Binding {
|
|
target: searchInput
|
|
property: "text"
|
|
value: root.filterText
|
|
when: !searchInput.activeFocus
|
|
}
|
|
}
|
|
// Placeholder, doubling as the SELECTION label —
|
|
// the filter is cleared on select, so the field is
|
|
// empty exactly when the label should show.
|
|
Text {
|
|
visible: searchInput.text === ""
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 12
|
|
anchors.rightMargin: 36
|
|
verticalAlignment: Text.AlignVCenter
|
|
text: root.creatingOpen
|
|
? QbzSession.tr("+ Create new playlist", QbzSession.trRev)
|
|
: (root.selectedLabel !== ""
|
|
? root.selectedLabel
|
|
: QbzSession.tr("Search playlists...", QbzSession.trRev))
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLink
|
|
elide: Text.ElideRight
|
|
}
|
|
// Clear-selection chip — only with a selection (or
|
|
// the inline-create state) and the list closed.
|
|
Rectangle {
|
|
visible: (root.selectedId !== "" || root.creatingOpen)
|
|
&& !root.dropdownOpen
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 10
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 20
|
|
height: 20
|
|
radius: 10
|
|
color: chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
name: "x"
|
|
width: 12
|
|
height: 12
|
|
tintName: chipArea.containsMouse ? "textPrimary" : "muted"
|
|
}
|
|
MouseArea {
|
|
id: chipArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
root.selectedId = ""
|
|
root.selectedLabel = ""
|
|
root.createName = ""
|
|
QbzPlaylistPicker.showCreate(false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Inline create — a second form group BELOW the picker
|
|
// (Tauri §1.4.6); the footer button confirms it.
|
|
Column {
|
|
width: parent.width - 48
|
|
spacing: 8
|
|
visible: root.creatingOpen
|
|
Text {
|
|
text: QbzSession.tr("Playlist Name", QbzSession.trRev)
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontLegal
|
|
font.weight: theme.weightMedium
|
|
}
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 43
|
|
radius: theme.radiusSm
|
|
color: theme.surfaceCard
|
|
border.width: 1
|
|
border.color: nameInput.activeFocus ? theme.accent : theme.borderSubtle
|
|
TextInput {
|
|
QbzTextEditMenu { }
|
|
id: nameInput
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 12
|
|
anchors.rightMargin: 12
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontLink
|
|
verticalAlignment: Text.AlignVCenter
|
|
clip: true
|
|
selectByMouse: true
|
|
enabled: !root.creating
|
|
text: root.createName
|
|
onTextEdited: root.createName = text
|
|
onAccepted: root.confirm()
|
|
Binding {
|
|
target: nameInput
|
|
property: "text"
|
|
value: root.createName
|
|
when: !nameInput.activeFocus
|
|
}
|
|
}
|
|
Text {
|
|
visible: nameInput.text === ""
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 12
|
|
anchors.rightMargin: 12
|
|
verticalAlignment: Text.AlignVCenter
|
|
text: QbzSession.tr("My Playlist", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLink
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
// --- "Already in" side column -----------------------------
|
|
// The containment answer as a right-hand column instead of an
|
|
// in-flow strip: the dropdown overlays the MAIN column only, so
|
|
// this list stays readable while the playlist list is open.
|
|
// Shown if and only if the index can back a claim (complete with
|
|
// matches, or updating under the explicit caveat) — an empty
|
|
// answer from an incomplete index renders nothing.
|
|
Item {
|
|
visible: root.alreadyShowing
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 69
|
|
anchors.bottom: footerDivider.top
|
|
anchors.right: parent.right
|
|
width: card.rightPanelW
|
|
|
|
Rectangle {
|
|
anchors.left: parent.left
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
width: 1
|
|
color: theme.borderSubtle
|
|
}
|
|
|
|
Column {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 17
|
|
anchors.rightMargin: 16
|
|
anchors.topMargin: 16
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: QbzSession.tr("Already in", QbzSession.trRev)
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontLegal
|
|
font.weight: theme.weightMedium
|
|
}
|
|
Text {
|
|
visible: root.alreadyState === "updating"
|
|
width: parent.width
|
|
text: QbzSession.tr("Playlist index updating...", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
font.italic: true
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
ListView {
|
|
width: parent.width
|
|
height: Math.max(0, parent.height - y - 12)
|
|
clip: true
|
|
spacing: 2
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
model: root.alreadyIn
|
|
delegate: Rectangle {
|
|
required property var modelData
|
|
width: parent ? parent.width : 0
|
|
height: 34
|
|
radius: theme.radiusSm
|
|
color: alreadyArea.containsMouse
|
|
? theme.surfaceHover : "transparent"
|
|
QbzIcon {
|
|
id: alreadyGlyph
|
|
visible: modelData.isLocal === true
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 6
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
name: "hard-drive"
|
|
width: 12
|
|
height: 12
|
|
tintName: "muted"
|
|
}
|
|
Text {
|
|
anchors.left: alreadyGlyph.visible
|
|
? alreadyGlyph.right : parent.left
|
|
anchors.leftMargin: 6
|
|
anchors.right: alreadyCount.visible
|
|
? alreadyCount.left : parent.right
|
|
anchors.rightMargin: 6
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: modelData.name
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontLegal
|
|
elide: Text.ElideRight
|
|
}
|
|
// The multi-track answer: "2 of 5". A full or
|
|
// single-track containment says it by presence.
|
|
Text {
|
|
id: alreadyCount
|
|
visible: root.trackCount > 1
|
|
&& modelData.contained < root.trackCount
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 6
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzSession.tr("{} of {}", QbzSession.trRev)
|
|
.replace("{}", modelData.contained)
|
|
.replace("{}", root.trackCount)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
}
|
|
MouseArea {
|
|
id: alreadyArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
// Selecting it is the useful action: "add the
|
|
// missing 3 of 5" is one click plus the
|
|
// dup-confirm's only-new answer.
|
|
onClicked: root.selectRow(modelData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Footer, pinned to the card bottom --------------------
|
|
// Keep a real footer band around the modal-standard 36px
|
|
// controls. The old divider was anchored directly to the Row's
|
|
// top edge, so both buttons physically touched the separator.
|
|
Item {
|
|
id: footerBar
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
height: 69
|
|
|
|
Row {
|
|
id: footerRow
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 24
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 10
|
|
|
|
SettingsButton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzSession.tr("Cancel", QbzSession.trRev)
|
|
btnHeight: 36
|
|
minWidth: 0
|
|
onClicked: QbzPlaylistPicker.close()
|
|
}
|
|
// The single accent confirm (ADR-008). Disabled until
|
|
// valid; QbzPrimaryButton supplies the active theme's
|
|
// primary fill and matching on-accent label colour.
|
|
QbzPrimaryButton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
btnHeight: 36
|
|
labelSize: 15
|
|
label: root.creating
|
|
? QbzSession.tr("Saving...", QbzSession.trRev)
|
|
: (root.creatingOpen
|
|
? QbzSession.tr("Create & Add", QbzSession.trRev)
|
|
: QbzSession.tr("Add", QbzSession.trRev))
|
|
btnEnabled: !root.creating
|
|
&& (root.creatingOpen
|
|
? root.createName.trim() !== ""
|
|
: root.selectedId !== "")
|
|
onClicked: root.confirm()
|
|
}
|
|
}
|
|
}
|
|
Rectangle {
|
|
id: footerDivider
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: footerBar.top
|
|
height: 1
|
|
color: theme.borderSubtle
|
|
}
|
|
|
|
// --- Dropdown overlay -------------------------------------
|
|
// Declared LAST inside the card so it paints above the body and
|
|
// the footer (declaration order IS z-order in QML), and it
|
|
// legitimately overflows the unclipped card bottom.
|
|
Rectangle {
|
|
id: dropdown
|
|
visible: root.dropdownOpen && root.doc.loading !== true
|
|
x: 24
|
|
// Anchored below the input by static arithmetic over fixed
|
|
// metrics — never an absolute-position binding: header 68 +
|
|
// 1 divider + 24 body pad + 20 label row + 8 spacing +
|
|
// 43 input + 4 gap.
|
|
y: 68 + 1 + 24 + 20 + 8 + 43 + 4
|
|
// MAIN column only — the "Already in" side column stays
|
|
// readable while the list is open (that is its whole point).
|
|
width: parent.width - card.rightPanelW - 48
|
|
// create row 41 + 1 divider, pinned last-used 41 + 1, 41 per
|
|
// shown row, 41 no-results, 1 + 34 overflow. Tauri max-height
|
|
// 320 + border.
|
|
height: Math.min(2 + 42 + (root.lastUsed !== null ? 42 : 0)
|
|
+ root.shown * 41
|
|
+ ((root.rows.length === 0 && root.filterText !== "") ? 41 : 0)
|
|
+ (root.overflow > 0 ? 35 : 0), 322)
|
|
radius: theme.radiusSm
|
|
color: theme.surfaceCard
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
clip: true
|
|
|
|
// Keep clicks on the list chrome from reaching the card
|
|
// swallower, which would close the list under the pointer.
|
|
MouseArea { anchors.fill: parent }
|
|
|
|
Flickable {
|
|
anchors.fill: parent
|
|
anchors.margins: 1
|
|
contentWidth: width
|
|
contentHeight: dropInner.implicitHeight
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
Column {
|
|
id: dropInner
|
|
width: parent.width
|
|
|
|
// "+ Create new playlist" — ALWAYS first, never
|
|
// filtered out (Tauri parity).
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 41
|
|
color: createArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
Text {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 12
|
|
anchors.rightMargin: 12
|
|
verticalAlignment: Text.AlignVCenter
|
|
text: QbzSession.tr("+ Create new playlist", QbzSession.trRev)
|
|
color: theme.accent
|
|
font.pixelSize: theme.fontLink
|
|
font.weight: theme.weightMedium
|
|
elide: Text.ElideRight
|
|
}
|
|
MouseArea {
|
|
id: createArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
root.selectedId = ""
|
|
root.selectedLabel = ""
|
|
root.filterText = ""
|
|
QbzPlaylistPicker.search("")
|
|
QbzPlaylistPicker.showCreate(true)
|
|
root.dropdownOpen = false
|
|
}
|
|
}
|
|
}
|
|
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
|
|
|
|
// Session's last successful target, pinned above the
|
|
// results. Rust already validated it (still listed,
|
|
// still writable) and de-duplicated it from `rows`;
|
|
// under a search it only survives a matching query.
|
|
Rectangle {
|
|
visible: root.lastUsed !== null
|
|
width: parent.width
|
|
height: visible ? 41 : 0
|
|
color: lastUsedArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
QbzIcon {
|
|
id: lastUsedGlyph
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 12
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
name: "clock"
|
|
width: 14
|
|
height: 14
|
|
tintName: "muted"
|
|
}
|
|
Text {
|
|
anchors.left: lastUsedGlyph.right
|
|
anchors.leftMargin: 8
|
|
anchors.right: lastUsedCaption.left
|
|
anchors.rightMargin: 8
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.lastUsed ? root.lastUsed.name : ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontLink
|
|
elide: Text.ElideRight
|
|
}
|
|
Text {
|
|
id: lastUsedCaption
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 12
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzSession.tr("Last playlist", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
font.italic: true
|
|
}
|
|
MouseArea {
|
|
id: lastUsedArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.selectRow(root.lastUsed)
|
|
}
|
|
}
|
|
Rectangle {
|
|
visible: root.lastUsed !== null
|
|
width: parent.width
|
|
height: visible ? 1 : 0
|
|
color: theme.borderSubtle
|
|
}
|
|
|
|
Repeater {
|
|
model: root.rows.slice(0, root.windowSize)
|
|
delegate: Rectangle {
|
|
required property var modelData
|
|
width: parent ? parent.width : 0
|
|
height: 41
|
|
color: rowArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
// LOCAL playlists (library.db) keep a small
|
|
// hard-drive glyph LEADING the name, muted —
|
|
// PlaylistPickerModal.slint:62-69. It is what
|
|
// tells a local target apart from a Qobuz one
|
|
// in a list that mixes both.
|
|
QbzIcon {
|
|
id: localGlyph
|
|
visible: modelData.isLocal === true
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 12
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
name: "hard-drive"
|
|
width: 14
|
|
height: 14
|
|
tintName: "muted"
|
|
}
|
|
Text {
|
|
anchors.left: localGlyph.visible ? localGlyph.right : parent.left
|
|
anchors.leftMargin: localGlyph.visible ? 8 : 12
|
|
anchors.right: countText.left
|
|
anchors.rightMargin: 8
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: modelData.name
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontLink
|
|
elide: Text.ElideRight
|
|
}
|
|
Text {
|
|
id: countText
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 12
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: modelData.tracksLine || ""
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
}
|
|
MouseArea {
|
|
id: rowArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.selectRow(modelData)
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
visible: root.rows.length === 0 && root.filterText !== ""
|
|
width: parent.width
|
|
height: visible ? 41 : 0
|
|
text: QbzSession.tr("No playlists found", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
|
|
// Overflow line — the rows past the 8-row window.
|
|
Rectangle {
|
|
visible: root.overflow > 0
|
|
width: parent.width
|
|
height: visible ? 1 : 0
|
|
color: theme.borderSubtle
|
|
}
|
|
Text {
|
|
visible: root.overflow > 0
|
|
width: parent.width
|
|
height: visible ? 34 : 0
|
|
text: QbzSession.tr("{} more playlist(s)...", QbzSession.trRev)
|
|
.replace("{}", root.overflow)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
font.italic: true
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============= Duplicate-confirm sub-modal ===========================
|
|
// Opens INSTEAD of adding when the chosen playlist already holds some of
|
|
// the carried tracks. It replaces the picker rather than stacking on it
|
|
// (the picker's own state is already parked in Rust).
|
|
Item {
|
|
anchors.fill: parent
|
|
visible: root.dup.open === true
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: "#bf000000"
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: QbzPlaylistPicker.cancelDuplicates()
|
|
onWheel: function (wheel) { wheel.accepted = true }
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.centerIn: parent
|
|
width: Math.min(parent.width - 80, 460)
|
|
height: dupPanel.implicitHeight
|
|
radius: theme.radiusLg
|
|
color: theme.surfaceCard
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onWheel: function (wheel) { wheel.accepted = true }
|
|
}
|
|
|
|
Column {
|
|
id: dupPanel
|
|
width: parent.width
|
|
padding: 24
|
|
spacing: 16
|
|
|
|
Item {
|
|
width: parent.width - 48
|
|
height: 28
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.right: dupClose.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzSession.tr("Some of these tracks were already added", QbzSession.trRev)
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontSection
|
|
font.weight: theme.weightSemibold
|
|
elide: Text.ElideRight
|
|
}
|
|
Item {
|
|
id: dupClose
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 28
|
|
height: 28
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
name: "x"
|
|
width: 17
|
|
height: 17
|
|
tintName: dupCloseArea.containsMouse ? "textPrimary" : "muted"
|
|
}
|
|
MouseArea {
|
|
id: dupCloseArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: QbzPlaylistPicker.cancelDuplicates()
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
width: parent.width - 48
|
|
text: QbzSession.tr("This playlist already contains {} of the {} tracks you're adding. Add only the new ones, or add everything including duplicates.", QbzSession.trRev)
|
|
.replace("{}", root.dup.duplicateCount || 0)
|
|
.replace("{}", root.trackCount)
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontLink
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 24
|
|
spacing: 12
|
|
SettingsButton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.dup.busy === true
|
|
? QbzSession.tr("Adding...", QbzSession.trRev)
|
|
: QbzSession.tr("Add all tracks", QbzSession.trRev)
|
|
btnHeight: 34
|
|
minWidth: 0
|
|
enabled: root.dup.busy !== true
|
|
onClicked: QbzPlaylistPicker.confirmDuplicates(true)
|
|
}
|
|
QbzPrimaryButton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
btnHeight: 34
|
|
label: root.dup.busy === true
|
|
? QbzSession.tr("Adding...", QbzSession.trRev)
|
|
: QbzSession.tr("Add only new tracks", QbzSession.trRev)
|
|
btnEnabled: root.dup.busy !== true
|
|
onClicked: QbzPlaylistPicker.confirmDuplicates(false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|