Ports the whole playlist-organisation domain, which had no counterpart in this frontend, plus the Library work the owner asked not to defer. Playlist Manager. New controller (playlist_manager_qt, _rows, _ops) over the already-ported folders_qt repository, which until now had zero consumers. Grid, list and folder-tree modes; sort, filter and search; folder cards and chips; drag reorder and up/down arrows. Reached from the sidebar's "..." menu, whose New folder / Manage playlists / Import rows had been rendering dimmed and inert behind a comment saying the seam did not exist. Folders. QbzFolderEdit plus the create/edit modals and PmFolderIcon: preset or custom-image icons, colour, hidden flag, delete. Deleting a folder nulls its LOCAL members' folder_id explicitly, because the app keeps the foreign_keys pragma off, so the schema's ON DELETE SET NULL never fires for them. QbzPlaylistEdit. One shared rename/description/delete editor replacing the inline popup in PlaylistView. Three corrections the old path had wrong: the description is carried through a rename instead of being written as NULL, a FOLLOWED playlist routes to unsubscribe (playlist/delete returns 200 and silently no-ops on a playlist you do not own), and the post-delete back navigation is conditional on actually being on that playlist's page, so deleting from the manager no longer navigates you off it. Importer. Spotify / Apple Music / Tidal / Deezer, over the shared qbz-playlist-import crate. Picker local mode. The reference's four-case matrix — target local or Qobuz, payload local refs or Qobuz ids. Local playlists list unconditionally and Qobuz ones only when online. Local refs onto a Qobuz playlist write the library.db sidecar at next_playlist_sidecar_position(pid, qobuz_count); the 0-based enumerate the reference used before collided slots and lost rows. The carried payload can no longer be parsed as a catalog id when it is a library row id. Unblocks the local bulk add-to-playlist. Library. A-Z jump strip, Albums list mode, Artists side panel, per-tab persistence and multi-select. Preferences land in the same <data-dir>/qbz/favorites_ui.json the shipping build writes, so switching between the two frontends keeps them. LibraryView is split into views/library/ on the way past 1800 lines. Local library. Group-by now persists; playback queues the order on screen rather than the SQL page order; switching album-identity mode invalidates the Artists tab; artist matching folds diacritics and compares normalized credit parts, so "Air" stops listing "Airbourne" and "Beyonce" stops being two artists. Offline. activate_offline never persisted a last-user marker, so every per-user path resolved to None and all local reads came back empty; a session that started offline saw no local playlists at all. The sidebar's load-once latch was function-local and therefore unreachable from the logout reset, so a second login never rebuilt the tree. Both are session-scoped now, and the sidebar Refresh row runs the local-safe verb instead of being dimmed with no way to recover. Also fixes, found while verifying rather than reported: artwork derivatives were written straight to their final path, so a QML Image resolving one mid-encode opened a truncated png and Qt cached that failure against the URL for the rest of the session; Library's track group separators referenced a Component declared in another delegate's scope and could never render; the genre filter was drawn on Tracks and Albums but applied only on All; an All-feed row called a player method that does not exist; and the sidebar's drop-hot highlight used Slint's #RRGGBBAA literals in Qt's #AARRGGBB slot. 17 new msgids across all 8 locales. cc
686 lines
32 KiB
QML
686 lines
32 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
|
|
// 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.
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: "#bf000000"
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: QbzPlaylistPicker.close()
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: card
|
|
anchors.centerIn: parent
|
|
width: Math.min(parent.width - 80, 490)
|
|
// 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.
|
|
|
|
// Swallow clicks so they never reach the scrim; doubles as
|
|
// "a click inside the card closes the list" (Tauri parity).
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: root.dropdownOpen = false
|
|
}
|
|
|
|
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 ---------------------------------------------
|
|
Column {
|
|
id: body
|
|
width: parent.width
|
|
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 {
|
|
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.surfaceHover : 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 {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Footer, pinned to the card bottom --------------------
|
|
Rectangle {
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: footerRow.top
|
|
height: 1
|
|
color: theme.borderSubtle
|
|
}
|
|
Row {
|
|
id: footerRow
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 24
|
|
anchors.bottom: parent.bottom
|
|
anchors.bottomMargin: 16
|
|
spacing: 12
|
|
|
|
SettingsButton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzSession.tr("Cancel", QbzSession.trRev)
|
|
btnHeight: 34
|
|
minWidth: 0
|
|
onClicked: QbzPlaylistPicker.close()
|
|
}
|
|
// The single accent confirm (ADR-008). Disabled until valid —
|
|
// this is what replaces Tauri's in-body error box.
|
|
QbzPrimaryButton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
btnHeight: 34
|
|
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()
|
|
}
|
|
}
|
|
|
|
// --- 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
|
|
width: parent.width - 48
|
|
// create row 41 + 1 divider, 41 per shown row, 41 no-results,
|
|
// 1 + 34 overflow. Tauri max-height 320 + border.
|
|
height: Math.min(2 + 42 + 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 }
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
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 }
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|