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
630 lines
27 KiB
QML
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
|
|
}
|
|
}
|