QbzLineEdit re-seeds its box only when `text` CHANGES and only while the box is not focused; QbzTextArea only while unfocused. Modals that reset their drafts on the close edge (the box still holds focus for that frame) or reopen with the seed they closed on produced neither, so the box kept the previous input while the draft said otherwise. Both controls gain `reset(value)`, which writes the box directly, and every modal form calls it from its open handler: playlist edit and create, MyQBZ create / rename / description, folder create and edit, the link resolver, the rip wizard and the library folder editor. The MyQBZ create name now resets on the open edge, and its edit panel re-seeds on every open, also for the same collection. Renaming a Qobuz playlist also patches the playlist manager's warm cache, which the editor seeds from before any fetch: reopening the editor right after Save showed the pre-save name until the background reload landed, and forever while offline. cc
587 lines
24 KiB
QML
587 lines
24 KiB
QML
// MyQbzModals — the THREE small MyQBZ modals in one file, as three sibling
|
|
// panels: Create (myqbz/CreateMyQbzModal.slint, 199 lines), Edit
|
|
// (MyQbzEditModal.slint, 147) and Mix (MyQbzMixModal.slint, 184). Slint keeps
|
|
// them apart only because each has its own global; they share the same
|
|
// `#bf000000` backdrop, centred surface-card panel and right-aligned action
|
|
// footer, and they are mutually exclusive in practice.
|
|
//
|
|
// A GLOBAL overlay: AppShell.qml mounts it once and each panel self-gates on
|
|
// its OWN document's `open` flag (`QbzMyQbz.createJson` / `editJson` /
|
|
// `mixJson`) — the same shape as Slint's four bare instantiations at
|
|
// AppShell.slint:814-824.
|
|
//
|
|
// Per-panel numbers that are NOT shared, and every one of them is deliberate:
|
|
// * width 420 / 420 / 440 (Create / Edit / Mix);
|
|
// * spacing 16 / 16 / 18;
|
|
// * Edit follows the current modal standard (20px padding, heading + 28x28
|
|
// close X, 10px footer spacing, 36px buttons); Create and Mix retain their
|
|
// reference-specific metrics until their own parity pass.
|
|
//
|
|
// Drop shadows (`drop-shadow-blur: 32px` on all three) are dropped: shadows
|
|
// render nothing on this port's software path. The scrims carry `radius:
|
|
// theme.radiusMd` so an opaque full-bleed child cannot paint into the shell
|
|
// frame's bezel corners (controls/DiscoverConfigModal.qml:59-75).
|
|
//
|
|
// TEXT STATE IS QML-LOCAL. `MyQbzCreateState.name` and
|
|
// `MyQbzEditState.draft-name` / `draft-description` are NOT bridge members:
|
|
// every submit invokable takes the value as an argument (`createSubmit(name)`,
|
|
// `editSubmitRename(name)`, `editSubmitDescription(description)`). The drafts
|
|
// are seeded from the document when a panel opens and cleared when it closes.
|
|
//
|
|
// ENTER submits through `QbzLineEdit`'s `accepted` signal, NEVER `committed`:
|
|
// `committed` also fires on focus-out (QbzLineEdit.qml:155), and a blur must
|
|
// not create or rename anything.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../theme"
|
|
|
|
Item {
|
|
id: root
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
|
|
|
// --- Documents (guarded parses; a raw JSON.parse in a binding throws on
|
|
// the pre-publish frame — PlaylistView.qml:55-61).
|
|
readonly property var createDoc: {
|
|
try { return JSON.parse(QbzMyQbz.createJson) } catch (e) { return ({}) }
|
|
}
|
|
readonly property var editDoc: {
|
|
try { return JSON.parse(QbzMyQbz.editJson) } catch (e) { return ({}) }
|
|
}
|
|
readonly property var mixDoc: {
|
|
try { return JSON.parse(QbzMyQbz.mixJson) } catch (e) { return ({}) }
|
|
}
|
|
|
|
readonly property bool createOpen: root.createDoc.open === true
|
|
readonly property bool editOpen: root.editDoc.open === true
|
|
readonly property bool mixOpen: root.mixDoc.open === true
|
|
|
|
visible: root.createOpen || root.editOpen || root.mixOpen
|
|
enabled: root.visible
|
|
|
|
// ================================================================
|
|
// PANEL 1 — Create Mixtape / Collection (CreateMyQbzModal.slint)
|
|
// ================================================================
|
|
Item {
|
|
id: createPanel
|
|
anchors.fill: parent
|
|
visible: root.createOpen
|
|
|
|
readonly property string kind: root.createDoc.kind || "mixtape"
|
|
readonly property bool isMixtape: createPanel.kind === "mixtape"
|
|
readonly property bool creating: root.createDoc.creating === true
|
|
property string name: ""
|
|
readonly property bool canCreate: createPanel.name !== "" && !createPanel.creating
|
|
|
|
// A fresh open starts with an empty field — reset on the OPEN edge,
|
|
// never the close edge: at close the box still holds focus for a
|
|
// frame, its focus-gated re-seed skips, and the next open found the
|
|
// draft already "" with the old text still in the box (the
|
|
// dirty-form report of 2026-09-14). `reset()` syncs the box itself.
|
|
Connections {
|
|
target: root
|
|
function onCreateOpenChanged() {
|
|
if (!root.createOpen)
|
|
return
|
|
createPanel.name = ""
|
|
createNameField.reset("")
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: theme.radiusMd
|
|
color: "#bf000000"
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: QbzMyQbz.createClose()
|
|
// Wheel-lock (the DiscoverConfigModal rule).
|
|
onWheel: function (wheel) { wheel.accepted = true }
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: Math.min(root.width - 80, 420)
|
|
height: createCol.implicitHeight + 48
|
|
x: Math.round((root.width - width) / 2)
|
|
y: Math.round((root.height - height) / 2)
|
|
radius: theme.radiusMd
|
|
color: theme.surfaceCard
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
// Wheel-lock (the DiscoverConfigModal rule).
|
|
onWheel: function (wheel) { wheel.accepted = true }
|
|
}
|
|
|
|
Column {
|
|
id: createCol
|
|
x: 24
|
|
y: 24
|
|
width: parent.width - 48
|
|
spacing: 16
|
|
|
|
// --- Title + close
|
|
Item {
|
|
width: parent.width
|
|
height: 28
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: Math.max(0, parent.width - 28)
|
|
text: createPanel.isMixtape ? root.t("Create Mixtape")
|
|
: root.t("Create Collection")
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontHeading
|
|
font.weight: theme.weightBold
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
Item {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 28
|
|
height: 28
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
name: "x"
|
|
width: 17
|
|
height: 17
|
|
tintName: createCloseArea.containsMouse ? "textPrimary" : "muted"
|
|
}
|
|
MouseArea {
|
|
id: createCloseArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: QbzMyQbz.createClose()
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- NAME
|
|
Column {
|
|
width: parent.width
|
|
spacing: 8
|
|
Text {
|
|
text: root.t("NAME")
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 1.5
|
|
}
|
|
QbzLineEdit {
|
|
id: createNameField
|
|
width: parent.width
|
|
height: 34
|
|
text: createPanel.name
|
|
onEdited: function (v) { createPanel.name = v }
|
|
onAccepted: function (v) {
|
|
if (createPanel.canCreate) QbzMyQbz.createSubmit(createPanel.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- KIND (12px between the radios here; the Add modal uses
|
|
// 16 — AddToMixtapeModal.slint:503).
|
|
Column {
|
|
width: parent.width
|
|
spacing: 8
|
|
Text {
|
|
text: root.t("KIND")
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 1.5
|
|
}
|
|
Row {
|
|
spacing: 12
|
|
QbzRadioOption {
|
|
label: root.t("Mixtapes")
|
|
selected: createPanel.isMixtape
|
|
onClicked: if (!createPanel.creating) QbzMyQbz.createSetKind("mixtape")
|
|
}
|
|
QbzRadioOption {
|
|
label: root.t("Collections")
|
|
selected: !createPanel.isMixtape
|
|
onClicked: if (!createPanel.creating) QbzMyQbz.createSetKind("collection")
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Footer
|
|
Row {
|
|
anchors.right: parent.right
|
|
spacing: 8
|
|
SettingsButton {
|
|
text: root.t("Cancel")
|
|
btnHeight: 38
|
|
minWidth: 0
|
|
onClicked: QbzMyQbz.createClose()
|
|
}
|
|
QbzPrimaryButton {
|
|
label: createPanel.isMixtape ? root.t("+ New Mixtape")
|
|
: root.t("+ New Collection")
|
|
btnHeight: 38
|
|
btnEnabled: createPanel.canCreate
|
|
onClicked: QbzMyQbz.createSubmit(createPanel.name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ================================================================
|
|
// PANEL 2 — Rename / Edit description / Delete (MyQbzEditModal.slint)
|
|
// ================================================================
|
|
Item {
|
|
id: editPanel
|
|
anchors.fill: parent
|
|
visible: root.editOpen
|
|
|
|
readonly property string mode: root.editDoc.mode || ""
|
|
readonly property bool isRename: editPanel.mode === "rename"
|
|
readonly property bool isDescription: editPanel.mode === "description"
|
|
readonly property bool isDelete: editPanel.mode === "delete"
|
|
readonly property bool busy: root.editDoc.busy === true
|
|
property string draftName: ""
|
|
property string draftDescription: ""
|
|
// :33-35 — busy blocks everything; rename additionally needs a
|
|
// non-empty name (Rust trims again).
|
|
readonly property bool canSubmit: editPanel.busy
|
|
? false : (editPanel.isRename ? editPanel.draftName !== "" : true)
|
|
|
|
// Prefill from the document (Rust fills `name` for the rename + the
|
|
// delete-confirm interpolation, `description` for the description
|
|
// body — myqbz_edit sets the drafts when it opens, which is what this
|
|
// reproduces).
|
|
//
|
|
// Keyed on mode+name, NOT on `open`: Rust may publish a second open
|
|
// document for a DIFFERENT collection without an intervening closed
|
|
// frame, and then `open` never changes and the prefill would be stale.
|
|
// Keying it on the whole document instead would clobber what the user
|
|
// is typing every time an unrelated field (`busy`) flips.
|
|
// The open flag rides along so a reopen on the SAME collection
|
|
// re-seeds too (a cancelled rename left its draft behind).
|
|
readonly property string editKey: (root.editOpen ? "1" : "0") + "|"
|
|
+ editPanel.mode + "|" + (root.editDoc.name || "")
|
|
onEditKeyChanged: {
|
|
editPanel.draftName = root.editDoc.name || ""
|
|
editPanel.draftDescription = root.editDoc.description || ""
|
|
if (root.editOpen) {
|
|
renameField.reset()
|
|
descriptionField.reset()
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: theme.radiusMd
|
|
color: "#bf000000"
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: QbzMyQbz.editClose()
|
|
// Wheel-lock (the DiscoverConfigModal rule).
|
|
onWheel: function (wheel) { wheel.accepted = true }
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: Math.min(root.width - 80, 420)
|
|
height: editCol.implicitHeight + 40
|
|
x: Math.round((root.width - width) / 2)
|
|
y: Math.round((root.height - height) / 2)
|
|
radius: theme.radiusMd
|
|
color: theme.surfaceCard
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
// Wheel-lock (the DiscoverConfigModal rule).
|
|
onWheel: function (wheel) { wheel.accepted = true }
|
|
}
|
|
|
|
Column {
|
|
id: editCol
|
|
x: 20
|
|
y: 20
|
|
width: parent.width - 40
|
|
spacing: 16
|
|
|
|
// --- Standard modal title + close X.
|
|
Item {
|
|
width: parent.width
|
|
height: 28
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: Math.max(0, parent.width - 28)
|
|
text: editPanel.isRename ? root.t("Rename")
|
|
: editPanel.isDescription ? root.t("Edit description")
|
|
: root.t("Delete")
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontHeading
|
|
font.weight: theme.weightSemibold
|
|
elide: Text.ElideRight
|
|
}
|
|
Item {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 28
|
|
height: 28
|
|
opacity: editPanel.busy ? 0.5 : 1.0
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
width: 17
|
|
height: 17
|
|
name: "x"
|
|
tintName: editCloseArea.containsMouse ? "textPrimary" : "muted"
|
|
}
|
|
MouseArea {
|
|
id: editCloseArea
|
|
anchors.fill: parent
|
|
enabled: !editPanel.busy
|
|
hoverEnabled: !editPanel.busy
|
|
cursorShape: editPanel.busy ? Qt.ArrowCursor
|
|
: Qt.PointingHandCursor
|
|
onClicked: QbzMyQbz.editClose()
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Body: rename
|
|
QbzLineEdit {
|
|
id: renameField
|
|
visible: editPanel.isRename
|
|
width: parent.width
|
|
height: visible ? 34 : 0
|
|
text: editPanel.draftName
|
|
onEdited: function (v) { editPanel.draftName = v }
|
|
onAccepted: function (v) {
|
|
if (editPanel.canSubmit) QbzMyQbz.editSubmitRename(editPanel.draftName)
|
|
}
|
|
}
|
|
|
|
// --- Body: description
|
|
QbzTextArea {
|
|
id: descriptionField
|
|
visible: editPanel.isDescription
|
|
width: parent.width
|
|
// A FLAT 96, never `visible ? 96 : 0`: a Column does not
|
|
// position invisible children, so the hidden arm already
|
|
// costs no layout height, while a 0-height text area
|
|
// hands its inner Flickable height = 0 - 8 - 8 = -16 —
|
|
// the silent negative-size trap QbzLineEdit.qml:166-167
|
|
// documents. The sibling QbzLineEdit is single-line and
|
|
// lands at a clean 0, so it keeps its ternary.
|
|
height: 96
|
|
wrapMode: TextEdit.WordWrap
|
|
text: editPanel.draftDescription
|
|
onEdited: function (v) { editPanel.draftDescription = v }
|
|
}
|
|
|
|
// --- Body: delete confirm (:105, DOUBLE quotes around the
|
|
// name, interpolated into the msgid's `{}`).
|
|
Text {
|
|
visible: editPanel.isDelete
|
|
width: parent.width
|
|
text: root.t("Delete \"{}\"? This cannot be undone.")
|
|
.replace("{}", root.editDoc.name || "")
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontBody
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
// --- Footer
|
|
Row {
|
|
anchors.right: parent.right
|
|
spacing: 10
|
|
SettingsButton {
|
|
text: root.t("Cancel")
|
|
btnHeight: 36
|
|
minWidth: 0
|
|
enabled: !editPanel.busy
|
|
onClicked: QbzMyQbz.editClose()
|
|
}
|
|
QbzPrimaryButton {
|
|
label: editPanel.isDelete ? root.t("Delete") : root.t("Save")
|
|
btnHeight: 36
|
|
labelSize: theme.fontBody
|
|
destructive: editPanel.isDelete
|
|
btnEnabled: editPanel.canSubmit
|
|
onClicked: {
|
|
if (editPanel.isRename)
|
|
QbzMyQbz.editSubmitRename(editPanel.draftName)
|
|
else if (editPanel.isDescription)
|
|
QbzMyQbz.editSubmitDescription(editPanel.draftDescription)
|
|
else
|
|
QbzMyQbz.editConfirmDelete()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ================================================================
|
|
// PANEL 3 — DJ-mix "Random queue" (MyQbzMixModal.slint)
|
|
// ================================================================
|
|
Item {
|
|
id: mixPanel
|
|
anchors.fill: parent
|
|
visible: root.mixOpen
|
|
|
|
readonly property bool loading: root.mixDoc.loading === true
|
|
readonly property bool busy: root.mixDoc.busy === true
|
|
readonly property var sizeOptions: root.mixDoc.sizeOptions || []
|
|
readonly property int selectedIndex: root.mixDoc.selectedIndex || 0
|
|
readonly property int selectedSize: root.mixDoc.selectedSize || 0
|
|
readonly property bool selectedIsAll: root.mixDoc.selectedIsAll === true
|
|
readonly property int uniqueCount: root.mixDoc.uniqueCount || 0
|
|
// :35-39
|
|
readonly property bool canShuffle: !mixPanel.loading && !mixPanel.busy
|
|
&& mixPanel.sizeOptions.length > 0 && mixPanel.selectedSize > 0
|
|
// The slider travels over option INDICES, not song counts; a single
|
|
// option (only "All (N)") renders a degenerate 0..0 track (:42).
|
|
readonly property int sliderMax: Math.max(0, mixPanel.sizeOptions.length - 1)
|
|
readonly property string sizeLabel: mixPanel.selectedIsAll
|
|
? root.t("All ({})").replace("{}", mixPanel.selectedSize)
|
|
: String(mixPanel.selectedSize)
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: theme.radiusMd
|
|
color: "#bf000000"
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: QbzMyQbz.mixClose()
|
|
// Wheel-lock (the DiscoverConfigModal rule).
|
|
onWheel: function (wheel) { wheel.accepted = true }
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: Math.min(root.width - 80, 440)
|
|
height: mixCol.implicitHeight + 48
|
|
x: Math.round((root.width - width) / 2)
|
|
y: Math.round((root.height - height) / 2)
|
|
radius: theme.radiusMd
|
|
color: theme.surfaceCard
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
// Wheel-lock (the DiscoverConfigModal rule).
|
|
onWheel: function (wheel) { wheel.accepted = true }
|
|
}
|
|
|
|
Column {
|
|
id: mixCol
|
|
x: 24
|
|
y: 24
|
|
width: parent.width - 48
|
|
spacing: 18
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: root.t("Random queue")
|
|
color: theme.textPrimary
|
|
font.pixelSize: 18
|
|
font.weight: theme.weightBold
|
|
elide: Text.ElideRight
|
|
}
|
|
Text {
|
|
width: parent.width
|
|
text: root.t("Create a random queue with songs from this collection. How many songs do you want?")
|
|
color: theme.textSecondary
|
|
font.pixelSize: 14
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
// --- Loading (:93-106): left-aligned, 8px spacing.
|
|
Row {
|
|
visible: mixPanel.loading
|
|
height: visible ? 18 : 0
|
|
spacing: 8
|
|
QbzSpinner {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
size: 18
|
|
}
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.t("Loading…")
|
|
color: theme.textSecondary
|
|
font.pixelSize: 14
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
|
|
// --- Size field (:109-154)
|
|
Column {
|
|
visible: !mixPanel.loading && mixPanel.sizeOptions.length > 0
|
|
width: parent.width
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: root.t("Number of songs")
|
|
color: theme.textPrimary
|
|
font.pixelSize: 13
|
|
font.weight: theme.weightMedium
|
|
}
|
|
Row {
|
|
width: parent.width
|
|
spacing: 12
|
|
Text {
|
|
id: sizeValue
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: Math.max(64, implicitWidth)
|
|
text: mixPanel.sizeLabel
|
|
color: theme.accent
|
|
font.pixelSize: 15
|
|
font.weight: theme.weightSemibold
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
QbzSlider {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: Math.max(0, parent.width - sizeValue.width - 12)
|
|
minimum: 0
|
|
maximum: mixPanel.sliderMax
|
|
value: mixPanel.selectedIndex
|
|
onChanged: function (v) { QbzMyQbz.mixSetIndex(v) }
|
|
}
|
|
}
|
|
Text {
|
|
width: parent.width
|
|
text: root.t("{} songs available").replace("{}", mixPanel.uniqueCount)
|
|
color: theme.textMuted
|
|
font.pixelSize: 12
|
|
}
|
|
}
|
|
|
|
// --- Footer: spacing 12 here, NOT 8 (:159).
|
|
Row {
|
|
anchors.right: parent.right
|
|
spacing: 12
|
|
SettingsButton {
|
|
text: root.t("Cancel")
|
|
btnHeight: 38
|
|
minWidth: 0
|
|
enabled: !mixPanel.busy
|
|
onClicked: QbzMyQbz.mixClose()
|
|
}
|
|
QbzPrimaryButton {
|
|
label: root.t("Add to queue")
|
|
btnHeight: 38
|
|
btnEnabled: mixPanel.canShuffle
|
|
onClicked: QbzMyQbz.mixShuffle()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|