Files
qbz/crates/qbz-qt/qml/controls/PlaylistCreateModal.qml
T
vicrodh 461238bd6f fix(qt): modal forms open clean, never with the last session's text
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
2026-09-14 05:22:44 -06:00

455 lines
21 KiB
QML

// PlaylistCreateModal — "New playlist" (primitives/CreatePlaylistModal.slint).
//
// WHAT THIS REPLACES. The sidebar "+" used to call `QbzShell.createPlaylist()`,
// which created a playlist literally named "New Playlist" and navigated to it.
// Four of this panel's five fields had no other door — and one of them, the
// offline-only toggle, had no door AT ALL for an online user, so someone with a
// Qobuz session could not create a LOCAL playlist without pulling the network
// cable. `playlist_create_qt`'s header has the table.
//
// Mounted ONCE in qml/shell/AppShell.qml beside PlaylistEditModal, with no `z`
// — that file's convention is DECLARATION ORDER, and ADR-009's ">= 3000" is
// satisfied structurally.
//
// ── SHAPE, from the reference ─────────────────────────────────────────────
// Name (LineEdit, Enter submits) · Description (multiline, optional) ·
// Folder (only when the user HAS folders) · "Make playlist public" ·
// "Offline-only playlist" (+ hint while locked) · Cancel / Create.
//
// ── THE DRAFTS LIVE HERE ──────────────────────────────────────────────────
// Name, description, folder index, public and offline-only are QML-LOCAL and
// reach Rust only as arguments to `createSubmit(...)` — MyQbzModals.qml's
// standing rule, the same one PlaylistEditModal.qml follows. Rust owns only
// `busy`, the offline lock and the folder list.
//
// ── THE FOLDER IS SENT AS AN ID, NOT AN INDEX ─────────────────────────────
// The reference keeps two parallel arrays (`folder-options` / `folder-ids`) and
// resolves the id from the index at submit time. Here the document is a list of
// `{id, name}` objects and the id travels: parallel arrays that must stay in
// step across a process boundary are a class of bug this port does not need,
// and index 0's EMPTY id is what "no folder" means — never the index itself.
//
// ── THE FOLDER PICKER'S VISIBILITY IS THE REFERENCE'S ─────────────────────
// `folder-options.length > 1` (CreatePlaylistModal.slint:118): the row is
// absent when the only option is "No folder". Plus one thing the reference does
// not need: it also hides on the OFFLINE-ONLY arm, because `folders_qt
// ::move_playlist` keys on a Qobuz `u64` and a `local:<uuid>` has none. Showing
// a picker whose choice is silently dropped is the dead-affordance class the
// port refuses; the reference never hit it because its own local arm ignores
// the field just as quietly.
//
// ── PUBLIC DIMS UNDER OFFLINE-ONLY ────────────────────────────────────────
// A local playlist has nothing to be public ON (reference :138-146). It dims to
// 0.4 and stops taking clicks — it is not merely ignored at submit.
//
// ── `busy` HOLDS THE MODAL OPEN ───────────────────────────────────────────
// Rust closes it, and only after the write landed. `closeCreate()` is REFUSED
// while busy, so the close X and the scrim go inert for that window rather than
// rendering and no-opping.
//
// ── ESCAPE IS A WRAPPING FocusScope, NOT A SIBLING ONE ────────────────────
// The name field takes active focus and QML propagates an unhandled key up the
// FOCUSED item's parent chain, so a sibling scope would be dead the moment the
// field is focused (the FolderModals.qml note).
//
// ── ENTER ─────────────────────────────────────────────────────────────────
// Through QbzLineEdit's `accepted`, NEVER `committed`: committed also fires on
// focus-out, and closing the modal blurs the field, which would create twice.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// Guarded parse: a raw JSON.parse in a binding throws on the pre-publish
// frame (PlaylistView.qml:55-61).
readonly property var doc: {
try { return JSON.parse(QbzPlaylistEdit.createJson) } catch (e) { return ({}) }
}
readonly property bool createOpen: root.doc.open === true
readonly property bool busy: root.doc.busy === true
readonly property bool offlineLocked: root.doc.offlineLocked === true
readonly property var folders: root.doc.folders || []
// --- Drafts -----------------------------------------------------------
property string draftName: ""
property string draftDescription: ""
property int draftFolderIndex: 0
property bool draftIsPublic: false
property bool draftOfflineOnly: false
readonly property bool canCreate: root.draftName.trim() !== "" && !root.busy
// The reference's `folder-options.length > 1`, plus the local-arm gate.
readonly property bool folderPickerVisible:
root.folders.length > 1 && !root.draftOfflineOnly
visible: root.createOpen
enabled: root.visible
// Seeds are consumed ONCE per open. A plain republish (the `busy` flip on
// submit) must NOT reseed — it would throw away what the user typed.
onCreateOpenChanged: {
if (root.createOpen) {
root.draftName = ""
root.draftDescription = ""
root.draftFolderIndex = 0
root.draftIsPublic = false
// Offline forces it ON and locks it (reference :184-216).
root.draftOfflineOnly = root.offlineLocked
// The boxes too (QbzLineEdit.reset()): "" is also what the
// drafts held after the last cancel, so nothing above re-seeds.
nameField.reset("")
descriptionField.reset("")
scope.forceActiveFocus()
nameField.focusField()
} else {
root._restoreShellFocus()
}
}
// §1.4.3 (2026-08-03 hotkeys-port contract): the FocusScope grabs focus on
// open and Qt strands it on the now-invisible scope at close, which kills
// the AppShell key dispatcher until the next click. Restore the shell root.
function _restoreShellFocus() {
var p = root
while (p.parent) {
if (p.parent.isQbzShellRoot === true) {
p.parent.forceActiveFocus()
return
}
p = p.parent
}
}
function submit() {
if (!root.canCreate)
return
// "" is ROOT. The id comes from the row, never from the index — and on
// the local arm the picker is not even rendered, so it is "" there by
// construction rather than by a second rule.
var fid = ""
if (root.folderPickerVisible
&& root.draftFolderIndex > 0
&& root.draftFolderIndex < root.folders.length)
fid = String(root.folders[root.draftFolderIndex].id || "")
QbzPlaylistEdit.createSubmit(root.draftName, root.draftDescription,
fid, root.draftIsPublic,
root.draftOfflineOnly)
}
FocusScope {
id: scope
anchors.fill: parent
visible: root.createOpen
enabled: root.createOpen
// Taken HERE rather than only in the open handler: an invisible item
// cannot hold active focus, and a handler on the document property is
// not ordered against the `visible` binding it shares a frame with.
onVisibleChanged: if (scope.visible) scope.forceActiveFocus()
Keys.onEscapePressed: function (event) {
QbzPlaylistEdit.closeCreate()
event.accepted = true
}
// `radius` is load-bearing: this Item fills AppShell's ROUNDED content
// frame and Qt Quick's clip is a rectangular scissor, so an opaque
// full-bleed child paints into the frame's four bezel corners.
// #bf000000 is Slint's #000000bf CONVERTED — Slint is #RRGGBBAA, Qt is
// #AARRGGBB.
Rectangle {
anchors.fill: parent
radius: theme.radiusMd
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: QbzPlaylistEdit.closeCreate()
// Wheel-lock (the DiscoverConfigModal rule).
onWheel: function (wheel) { wheel.accepted = true }
}
}
Rectangle {
width: Math.min(root.width - 80, 420)
height: col.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
// FIRST child: swallows clicks so they never reach the scrim.
MouseArea {
anchors.fill: parent
// Wheel-lock (the DiscoverConfigModal rule).
onWheel: function (wheel) { wheel.accepted = true }
}
Column {
id: col
x: 20
y: 20
width: parent.width - 40
spacing: 16
// ================= 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: root.t("New playlist")
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: root.busy ? 0.5 : 1.0
QbzIcon {
anchors.centerIn: parent
width: 17
height: 17
name: "x"
tintName: closeArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: closeArea
anchors.fill: parent
// `closeCreate()` is refused while the write is in
// flight, so the affordance goes with it rather
// than lying.
enabled: !root.busy
hoverEnabled: !root.busy
cursorShape: root.busy ? Qt.ArrowCursor
: Qt.PointingHandCursor
onClicked: QbzPlaylistEdit.closeCreate()
}
}
}
// ================= Name =================================
Column {
width: parent.width
spacing: 8
Text {
text: root.t("Name")
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
QbzLineEdit {
id: nameField
width: parent.width
text: root.draftName
placeholder: root.t("My Playlist")
enabled: !root.busy
onEdited: function (value) { root.draftName = value }
onAccepted: function (value) {
root.draftName = value
root.submit()
}
}
}
// ================= Description ==========================
Column {
width: parent.width
spacing: 8
Text {
text: root.t("Description (optional)")
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
QbzTextArea {
id: descriptionField
width: parent.width
height: 80
text: root.draftDescription
enabled: !root.busy
onEdited: function (value) { root.draftDescription = value }
}
}
// ================= Folder ===============================
// A Column skips invisible children, so this leaves no gap
// when the user has no folders (or is on the local arm).
Column {
width: parent.width
spacing: 8
visible: root.folderPickerVisible
Text {
text: root.t("Folder")
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
QbzSelect {
width: parent.width
popupWidth: parent.width
enabled: !root.busy
// QbzSelect takes plain labels; the ids stay on
// root.folders and are resolved by INDEX at submit.
options: root.folders.map(function (f) { return String(f.name) })
currentIndex: root.draftFolderIndex
onSelected: function (i) { root.draftFolderIndex = i }
}
}
// ================= Public ===============================
// Meaningless for a local playlist, so it dims AND locks while
// offline-only is on (reference :138-146).
//
// An Item with a FIXED height, not a Row: a Row's height comes
// from its tallest child, so anchoring a child to
// `parent.verticalCenter` inside one is a binding loop.
Item {
width: parent.width
height: 20
opacity: root.draftOfflineOnly ? 0.4 : 1.0
Item {
id: publicHit
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 20
height: 20
QbzCheckbox {
anchors.centerIn: parent
checked: root.draftIsPublic
enabled: !root.busy && !root.draftOfflineOnly
// QbzCheckbox never self-flips; the owner of the
// state does.
onToggled: root.draftIsPublic = !root.draftIsPublic
}
}
Text {
anchors.left: publicHit.right
anchors.leftMargin: 10
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.t("Make playlist public")
color: theme.textSecondary
font.pixelSize: 15
elide: Text.ElideRight
}
}
// ================= Offline-only =========================
// D8: creates a LOCAL playlist (library.db, id
// `local:<uuid>`) that never reaches Qobuz. Forced ON and
// LOCKED while the app is offline, with the hint under it.
Column {
width: parent.width
spacing: 6
Item {
width: parent.width
height: 20
opacity: root.offlineLocked ? 0.7 : 1.0
Item {
id: offlineHit
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 20
height: 20
QbzCheckbox {
anchors.centerIn: parent
checked: root.draftOfflineOnly
enabled: !root.busy && !root.offlineLocked
onToggled: root.draftOfflineOnly = !root.draftOfflineOnly
}
}
Text {
anchors.left: offlineHit.right
anchors.leftMargin: 10
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.t("Offline-only playlist")
color: theme.textSecondary
font.pixelSize: 15
elide: Text.ElideRight
}
}
// Hint indented to the label's left edge (20px checkbox +
// 10px spacing), so it reads as the row's description.
Text {
visible: root.offlineLocked
x: 30
width: parent.width - 30
text: root.t("Created locally — you're offline")
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
// ================= Footer ===============================
Item {
width: parent.width
height: 36
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
// Cancel — the reference's neutral button: an
// elevated fill that lifts to surface-hover.
Rectangle {
width: cancelLabel.implicitWidth + 28
height: 36
radius: theme.radiusSm
color: (cancelArea.containsMouse && !root.busy)
? theme.surfaceElevatedHover : theme.surfaceElevated
opacity: root.busy ? 0.5 : 1.0
Text {
id: cancelLabel
anchors.centerIn: parent
text: root.t("Cancel")
color: theme.textPrimary
font.pixelSize: 15
}
MouseArea {
id: cancelArea
anchors.fill: parent
hoverEnabled: true
enabled: !root.busy
cursorShape: root.busy ? Qt.ArrowCursor
: Qt.PointingHandCursor
onClicked: QbzPlaylistEdit.closeCreate()
}
}
// Create. The reference's `#ffffffd6` hover on an
// accent fill and its hardcoded `#ffffff` label are
// NOT copied — both wash out on the light palettes,
// which is the same call PlaylistEditModal made.
QbzPrimaryButton {
label: root.busy ? root.t("Creating...") : root.t("Create")
btnHeight: 36
labelSize: 15
btnEnabled: root.canCreate
onClicked: root.submit()
}
}
}
}
}
}
}