Files
qbz/crates/qbz-qt/qml/controls/RipWizardModal.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

630 lines
27 KiB
QML

// The rip wizard — say what is about to happen, let it be corrected, do it.
//
// Three steps, and each one exists because of something the first version got
// wrong by not asking:
//
// 1. WHAT — which disc, how many tracks, and that the output is FLAC. A
// ripper that silently picks a format is one you find out about later.
// 2. TRACKS — editable. A CD-DA carries no titles at all, so "named wrong"
// is the ordinary case, and once files are on disk it is a rename job.
// 3. LIBRARY — a ripped album the library cannot see is a folder full of
// files. The wizard already knows whether the destination sits inside a
// registered folder, so it offers the right follow-up rather than a
// generic one.
//
// THE FORM OWNS ITS OWN STATE. Rust publishes seeds on open and never
// republishes over them (the `TrackReplacementModal` convention) — otherwise a
// late publish would overwrite what the user is typing.
//
// Mounted ONCE in AppShell: starting the rip RENAMES the session underneath,
// so a modal parented into the pane would be torn down by its own success.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
visible: doc.open === true
readonly property var doc: {
try { return JSON.parse(QbzLocal.localRipPlan) } catch (e) { return ({}) }
}
property int step: 0
property string album: ""
property string albumArtist: ""
property string year: ""
/// [{number, title, artist, duration}] — a COPY of the seeds, because the
/// document's array is replaced wholesale on every publish and editing a
/// binding's source array is how half the edits vanish.
property var tracks: []
/// "none" | "rescan" | "add"
property string libraryChoice: "none"
/// Bumped on every tick, so the "N of M selected" line and the Start
/// button re-evaluate. The track array is a plain `var` mutated in place
/// (see the delegate) and mutating it notifies nothing by itself.
property int selectionRev: 0
readonly property int selectedCount: {
var _ = root.selectionRev
var n = 0
for (var i = 0; i < root.tracks.length; i++)
if (root.tracks[i].selected) n++
return n
}
readonly property bool canStart: (doc.destination || "") !== ""
&& root.selectedCount > 0
function setSelected(i, on) {
root.tracks[i].selected = on
root.selectionRev += 1
}
function selectAll(on) {
for (var i = 0; i < root.tracks.length; i++)
root.tracks[i].selected = on
root.selectionRev += 1
}
function tr(s) { return QbzSession.tr(s, QbzSession.trRev) }
onVisibleChanged: {
if (!visible)
return
step = 0
album = doc.album || ""
albumArtist = doc.albumArtist || ""
year = doc.year || ""
// The boxes too (QbzLineEdit.reset()): a disc re-read with the same
// tags changes no draft above.
albumField.reset()
yearField.reset()
albumArtistField.reset()
libraryChoice = "none"
var copy = []
var src = doc.tracks || []
for (var i = 0; i < src.length; i++) {
copy.push({ "number": src[i].number, "title": src[i].title,
"artist": src[i].artist, "duration": src[i].duration,
"selected": src[i].selected !== false })
}
tracks = copy
selectionRev += 1
}
// The library question can only be answered once a destination exists, so
// the default follows the verdict rather than sitting on "none" and
// quietly doing nothing.
Connections {
target: QbzLocal
function onLocalRipPlanChanged() {
if (!root.visible)
return
var st = root.doc.libraryState || "unknown"
if (st === "inside") root.libraryChoice = "rescan"
else if (st === "outside") root.libraryChoice = "add"
}
}
function start() {
QbzLocal.ripStart(JSON.stringify({
"album": root.album,
"albumArtist": root.albumArtist,
"year": root.year,
"destination": root.doc.destination || "",
"tracks": root.tracks,
"library": root.libraryChoice
}))
}
QbzTheme { id: theme }
Rectangle {
anchors.fill: parent
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: QbzLocal.ripWizardClose()
// Wheel-lock (the DiscoverConfigModal rule).
onWheel: function (wheel) { wheel.accepted = true }
}
}
Rectangle {
id: card
anchors.centerIn: parent
width: Math.min(parent.width - 80, 660)
height: Math.min(parent.height * 0.9, 620)
radius: theme.radiusLg
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
clip: true
MouseArea {
anchors.fill: parent
// Wheel-lock (the DiscoverConfigModal rule).
onWheel: function (wheel) { wheel.accepted = true }
}
// ---- Header + step dots ----------------------------------------
Item {
id: header
width: parent.width
height: 72
Column {
anchors.left: parent.left
anchors.leftMargin: 24
anchors.right: stepRow.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.tr("Rip this CD")
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Text {
width: parent.width
text: [root.tr("What will happen"), root.tr("Track names"),
root.tr("Your library")][root.step]
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
Row {
id: stepRow
anchors.right: parent.right
anchors.rightMargin: 24
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Repeater {
model: 3
Rectangle {
required property int index
width: 7
height: 7
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: index === root.step ? theme.accent : theme.borderSubtle
}
}
}
}
Rectangle { y: header.height; width: parent.width; height: 1; color: theme.borderSubtle }
// ---- Body -------------------------------------------------------
Item {
id: body
y: header.height + 1
width: parent.width
height: card.height - header.height - 1 - footer.height
// ===== Step 1: what will happen =============================
Flickable {
visible: root.step === 0
anchors.fill: parent
contentWidth: width
contentHeight: whatCol.height + 40
clip: true
boundsBehavior: Flickable.StopAtBounds
Column {
id: whatCol
x: 24
y: 20
width: parent.width - 48
spacing: 14
Text {
width: parent.width
wrapMode: Text.WordWrap
text: root.tr("Every track on the disc is read and written as a FLAC file — one folder per album, one file per track, with tags.")
color: theme.textSecondary
font.pixelSize: theme.fontLink
}
// FLAC is stated, not implied. It is the only format this
// app writes, and a ripper that picks one silently is one
// you find out about later.
Row {
spacing: 8
QbzIcon { name: "audio-lines"; width: 15; height: 15
anchors.verticalCenter: parent.verticalCenter; tintName: "accent" }
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.tr("Format: FLAC (lossless) — the only format QBZ writes")
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
// Album / artist / year, editable here because they name
// the FOLDER as well as the tags.
//
// Two explicit ROWS, not a Grid. A Grid sizes each column
// to its widest cell, so the full-width "Album artist"
// field below made column 1 as wide as the card and shoved
// the Year field off the right edge — the layout only
// looked right until a cell disagreed with its column.
Row {
width: parent.width
spacing: 12
Column {
width: (parent.width - 12) * 0.66
spacing: 4
Text { text: root.tr("Album"); color: theme.textMuted
font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
id: albumField
text: root.album
onEdited: function (v) { root.album = v }
}
}
Column {
width: (parent.width - 12) * 0.34
spacing: 4
Text { text: root.tr("Year"); color: theme.textMuted
font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
id: yearField
text: root.year
onEdited: function (v) { root.year = v }
}
}
}
Column {
width: parent.width
spacing: 4
Text { text: root.tr("Album artist"); color: theme.textMuted
font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
id: albumArtistField
text: root.albumArtist
onEdited: function (v) { root.albumArtist = v }
}
}
// Destination — asked, never guessed.
Column {
width: parent.width
spacing: 6
Text { text: root.tr("Where the album goes"); color: theme.textMuted
font.pixelSize: theme.fontLegal }
Row {
width: parent.width
spacing: 10
SettingsButton {
id: pickBtn
anchors.verticalCenter: parent.verticalCenter
text: (root.doc.destination || "") === ""
? root.tr("Choose folder…") : root.tr("Change…")
btnHeight: 32
minWidth: 0
enabled: root.doc.picking !== true
onClicked: QbzLocal.ripPickDestination()
HoverHandler {
onHoveredChanged: tips.hover(hovered, pickBtn, "rip-pick",
root.tr("Pick the folder the album folder will be created in"))
}
}
QbzSpinner {
anchors.verticalCenter: parent.verticalCenter
size: 15
visible: root.doc.picking === true
}
Text {
anchors.verticalCenter: parent.verticalCenter
width: whatCol.width - pickBtn.width - 30
text: (root.doc.destination || "") === ""
? root.tr("Nothing chosen yet") : root.doc.destination
color: (root.doc.destination || "") === ""
? theme.textMuted : theme.textSecondary
font.pixelSize: theme.fontLegal
elide: Text.ElideMiddle
}
}
}
}
}
// ===== Step 2: track names + what to rip ====================
//
// Everything is ticked on arrival and the user goes from all to
// fewer. A wizard that starts empty makes the common case — rip
// the whole disc — the one that costs the most clicks.
Item {
visible: root.step === 1
anchors.fill: parent
Item {
id: listHead
width: parent.width
height: 38
Row {
anchors.left: parent.left
anchors.leftMargin: 24
anchors.right: parent.right
anchors.rightMargin: 24
anchors.verticalCenter: parent.verticalCenter
spacing: 10
RipTick {
id: allTick
anchors.verticalCenter: parent.verticalCenter
checked: root.selectedCount === root.tracks.length
&& root.tracks.length > 0
partial: root.selectedCount > 0
&& root.selectedCount < root.tracks.length
onToggled: root.selectAll(root.selectedCount < root.tracks.length)
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.selectedCount + " / " + root.tracks.length + " "
+ root.tr("selected")
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: theme.borderSubtle
}
}
ListView {
y: listHead.height
width: parent.width
height: parent.height - listHead.height
clip: true
model: root.tracks.length
boundsBehavior: Flickable.StopAtBounds
delegate: Item {
id: rowItem
required property int index
readonly property bool on: {
var _ = root.selectionRev
return root.tracks[rowItem.index].selected === true
}
width: ListView.view.width
height: 46
Row {
anchors.fill: parent
anchors.leftMargin: 24
anchors.rightMargin: 24
spacing: 10
RipTick {
anchors.verticalCenter: parent.verticalCenter
checked: rowItem.on
onToggled: root.setSelected(rowItem.index, !rowItem.on)
}
Text {
width: 22
anchors.verticalCenter: parent.verticalCenter
text: root.tracks[rowItem.index].number
color: theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignRight
}
QbzLineEdit {
width: parent.width - 18 - 22 - 48 - 30
anchors.verticalCenter: parent.verticalCenter
// An unticked track is not going to be written,
// so its name is not worth typing.
enabled: rowItem.on
opacity: rowItem.on ? 1.0 : 0.45
text: root.tracks[rowItem.index].title
// Write back into the COPY. Mutating a `var`
// array in place does not notify, which is fine
// for the TITLE precisely because nothing binds
// to it — `start()` reads it once, on submit.
// The tick does bind, which is why selection
// goes through `setSelected` and its counter.
onEdited: function (v) {
root.tracks[rowItem.index].title = v
}
}
Text {
width: 48
anchors.verticalCenter: parent.verticalCenter
text: root.tracks[rowItem.index].duration
color: theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignRight
opacity: rowItem.on ? 1.0 : 0.45
}
}
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: theme.borderSubtle
}
}
}
}
// ===== Step 3: the library ==================================
Column {
visible: root.step === 2
x: 24
y: 20
width: parent.width - 48
spacing: 14
Text {
width: parent.width
wrapMode: Text.WordWrap
text: root.doc.libraryState === "inside"
? root.tr("This folder is already part of your Local Library.")
: (root.doc.libraryState === "outside"
? root.tr("This folder is outside your Local Library, so the album will not appear there on its own.")
: root.tr("Choose a destination first and this step will know what to offer."))
color: theme.textSecondary
font.pixelSize: theme.fontLink
}
Text {
width: parent.width
visible: root.doc.libraryState === "inside"
wrapMode: Text.WordWrap
text: root.doc.libraryFolder || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideMiddle
}
// Two radio rows, both spelled out. "Do nothing" is a real
// choice and is offered as one rather than being the silent
// default nobody chose.
Repeater {
model: root.doc.libraryState === "inside"
? [{ "id": "rescan", "label": root.tr("Re-scan that folder when the rip finishes"),
"hint": root.tr("Only that folder, not your whole library.") },
{ "id": "none", "label": root.tr("Do nothing"),
"hint": root.tr("The album appears the next time you scan.") }]
: (root.doc.libraryState === "outside"
? [{ "id": "add", "label": root.tr("Add this folder to Local Library and scan it"),
"hint": root.tr("Future rips into the same folder will be picked up too.") },
{ "id": "none", "label": root.tr("Do nothing"),
"hint": root.tr("The files are written; the library will not know about them.") }]
: [])
Item {
id: optRow
required property var modelData
width: parent.width
height: 46
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Rectangle {
width: 16
height: 16
radius: 8
anchors.verticalCenter: parent.verticalCenter
color: "transparent"
border.width: 1
border.color: root.libraryChoice === optRow.modelData.id
? theme.accent : theme.borderSubtle
Rectangle {
anchors.centerIn: parent
width: 8
height: 8
radius: 4
visible: root.libraryChoice === optRow.modelData.id
color: theme.accent
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
text: optRow.modelData.label
color: theme.textPrimary
font.pixelSize: theme.fontLink
}
Text {
text: optRow.modelData.hint
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.libraryChoice = optRow.modelData.id
}
}
}
}
}
// ---- Footer -----------------------------------------------------
Item {
id: footer
width: parent.width
height: 92
anchors.bottom: parent.bottom
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
// The honest scope note. Small, muted, on every step: this is a
// convenience ripper, and somebody who needs AccurateRip should
// learn that here rather than after a 79-minute disc.
Text {
anchors.left: parent.left
anchors.leftMargin: 24
anchors.right: parent.right
anchors.rightMargin: 24
anchors.top: parent.top
anchors.topMargin: 12
wrapMode: Text.WordWrap
text: root.tr("A simple ripper, made to get your CDs into the library quickly. For AccurateRip checks and advanced options, use a dedicated tool.")
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
Row {
anchors.right: parent.right
anchors.rightMargin: 24
anchors.bottom: parent.bottom
anchors.bottomMargin: 14
spacing: 12
SettingsButton {
id: backBtn
anchors.verticalCenter: parent.verticalCenter
text: root.step === 0 ? root.tr("Cancel") : root.tr("Back")
btnHeight: 34
minWidth: 0
onClicked: {
if (root.step === 0) QbzLocal.ripWizardClose()
else root.step -= 1
}
HoverHandler {
onHoveredChanged: tips.hover(hovered, backBtn, "rip-back",
root.step === 0 ? root.tr("Close without ripping anything")
: root.tr("Go back a step"))
}
}
QbzPrimaryButton {
id: nextBtn
anchors.verticalCenter: parent.verticalCenter
btnHeight: 34
label: root.step < 2 ? root.tr("Next") : root.tr("Start ripping")
// The destination gates the LAST step only: a user should
// be able to fix the titles before being sent to a folder
// chooser.
btnEnabled: root.step < 2 || root.canStart
onClicked: {
if (root.step < 2) root.step += 1
else root.start()
}
HoverHandler {
onHoveredChanged: tips.hover(hovered, nextBtn, "rip-next",
root.step < 2
? root.tr("Go to the next step")
: (root.selectedCount === 0
? root.tr("Pick at least one track to rip.")
: (root.canStart
? root.tr("Read the disc and write the FLAC files")
: root.tr("Choose a destination folder first"))))
}
}
}
}
}
QbzTooltip {
id: tips
anchors.fill: parent
z: 900
}
}