feat(qt): playlist manager, folders, importer and the Library parity round
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
This commit is contained in:
115 files changed
+20778
-1637
No files matched your search
@@ -0,0 +1,685 @@
|
||||
// "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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user