feat(qt): the disc surfaces — rip wizard, metadata correction, and a pane that
can be read RIP WIZARD, three steps, each answering something the first version got wrong by not asking. What will happen — which disc, how many tracks, and that the output is FLAC, stated rather than implied. The tracks, editable, because 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. And what happens afterwards: a ripped album the library cannot see is a folder full of files, so the destination is compared against the registered folders — by path COMPONENTS, not by string prefix, since /music-backup starts with /music and is not inside it — and the right follow-up is offered, naming the folder it would re-scan. "Do nothing" is an explicit choice rather than the silent default. The follow-up runs only on success. Tracks can be deselected. Everything is ticked on arrival and the user goes from all to fewer; a wizard that starts empty makes the common case the one that costs the most clicks. On the Rust side an absent `selected` means yes, so an older form cannot silently rip nothing. A PROGRESS PANEL behind the ripping chip, which is a door rather than a label: it is the only thing on screen during a rip and the two questions it could not answer are the ones that matter. A soft lock says not to eject — soft because nothing here can hold a tray shut, so the honest move is to say it and keep saying it. Per-track state with a bar on the row that is actually moving. Stopping is offered, deletes nothing, and says so; a cancelled job does not jump to 100 %, which is the one lie a progress bar must not tell. CORRECT DISC DETAILS, for a CD and a SACD image and for neither on a folder, which has no identity to write a correction under. The list shows what distinguishes four pressings of one album rather than four identical titles, and flags a track count that disagrees with the disc. Selecting is not applying: the candidate is fetched in full and its track list shown, because that list is the only thing proving a plausible title is the right pressing. Applying renames the live session without renumbering ids — a re-open would orphan a playing track — and writes a user row. Only MusicBrainz re-resolves the cover; Discogs ids are not Cover Art Archive keys and a wrong cover is worse than the old one. The correction can be dropped, because without an escape a bad apply is permanent and that makes people afraid of the button. THE PANE SPLITS BY DISC. A box set scans as one album — measured on a 34-track set whose album_group_key is the box root for every file while the disc number sits in the data unused — so the pane was a flat list 247 rows long. The key carries the disc, unconditionally, because it round-trips through the play-album lookup. Its own client-side filter over what is already rendered, since finding one track meant scrolling past the other 246. EPHEMERAL TRACKS LOSE WHAT CANNOT OUTLIVE THEM. queue_qt has published isEphemeral on every row for a while and the queue panel respected it; the bar never read it. The heart goes inert, the add menu keeps only its queue actions, and the row menu comes back with those three and nothing else. Absent rather than rendered-and-inert. Also: a spinner on Open that blocks a second click onto a drive already being read, held by a counting RAII guard because opening is a relay and the chip must not blink off in the seam; the now-playing card routes a disc to the pane it came from instead of to an album id no catalogue can open; the file pickers use rfd, the platform's own dialog, which the POC-NOTE said was a five-line change once the dependency existed — it existed, Settings had been calling it from this crate for weeks, and on macOS the four Linux choosers do not exist so Open folder and Open SACD image opened nothing at all, in silence; the empty state stands down for the Open pane, which on a machine with no library drew both fully painted; tooltips on every disc control through one hover() helper, since two call sites had already mismatched their key, which does not fail — the bubble simply never goes away. cc
This commit is contained in:
33 files changed
+3809
-281
No files matched your search
@@ -347,6 +347,10 @@ ApplicationWindow {
|
||||
// spinning, since its `loading` flag is only ever taken down by a
|
||||
// publish.
|
||||
QbzTrackReplace.boot()
|
||||
// The disc-metadata modal. Same story: closed until the pane's search
|
||||
// button opens it, and without this line the search would spin
|
||||
// forever, because only a publish takes `searching` back down.
|
||||
QbzDiscMeta.boot()
|
||||
// HiFi Wizard. Nothing to seed — the modal is closed until the
|
||||
// Settings > Audio row opens it — but without this line every publish
|
||||
// the wizard makes is dropped on the floor: the health verdict, the
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13.172 2a2 2 0 0 1 1.414.586l6.71 6.71a2.4 2.4 0 0 1 0 3.408l-4.592 4.592a2.4 2.4 0 0 1-3.408 0l-6.71-6.71A2 2 0 0 1 6 9.172V3a1 1 0 0 1 1-1z" />
|
||||
<path d="M2 7v6.172a2 2 0 0 0 .586 1.414l6.71 6.71a2.4 2.4 0 0 0 3.191.193" />
|
||||
<circle cx="10.5" cy="6.5" r=".5" fill="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 474 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13.172 2a2 2 0 0 1 1.414.586l6.71 6.71a2.4 2.4 0 0 1 0 3.408l-4.592 4.592a2.4 2.4 0 0 1-3.408 0l-6.71-6.71A2 2 0 0 1 6 9.172V3a1 1 0 0 1 1-1z" />
|
||||
<path d="M2 7v6.172a2 2 0 0 0 .586 1.414l6.71 6.71a2.4 2.4 0 0 0 3.191.193" />
|
||||
<circle cx="10.5" cy="6.5" r=".5" fill="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 474 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13.172 2a2 2 0 0 1 1.414.586l6.71 6.71a2.4 2.4 0 0 1 0 3.408l-4.592 4.592a2.4 2.4 0 0 1-3.408 0l-6.71-6.71A2 2 0 0 1 6 9.172V3a1 1 0 0 1 1-1z" />
|
||||
<path d="M2 7v6.172a2 2 0 0 0 .586 1.414l6.71 6.71a2.4 2.4 0 0 0 3.191.193" />
|
||||
<circle cx="10.5" cy="6.5" r=".5" fill="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 474 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13.172 2a2 2 0 0 1 1.414.586l6.71 6.71a2.4 2.4 0 0 1 0 3.408l-4.592 4.592a2.4 2.4 0 0 1-3.408 0l-6.71-6.71A2 2 0 0 1 6 9.172V3a1 1 0 0 1 1-1z" />
|
||||
<path d="M2 7v6.172a2 2 0 0 0 .586 1.414l6.71 6.71a2.4 2.4 0 0 0 3.191.193" />
|
||||
<circle cx="10.5" cy="6.5" r=".5" fill="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 474 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13.172 2a2 2 0 0 1 1.414.586l6.71 6.71a2.4 2.4 0 0 1 0 3.408l-4.592 4.592a2.4 2.4 0 0 1-3.408 0l-6.71-6.71A2 2 0 0 1 6 9.172V3a1 1 0 0 1 1-1z" />
|
||||
<path d="M2 7v6.172a2 2 0 0 0 .586 1.414l6.71 6.71a2.4 2.4 0 0 0 3.191.193" />
|
||||
<circle cx="10.5" cy="6.5" r=".5" fill="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 474 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13.172 2a2 2 0 0 1 1.414.586l6.71 6.71a2.4 2.4 0 0 1 0 3.408l-4.592 4.592a2.4 2.4 0 0 1-3.408 0l-6.71-6.71A2 2 0 0 1 6 9.172V3a1 1 0 0 1 1-1z" />
|
||||
<path d="M2 7v6.172a2 2 0 0 0 .586 1.414l6.71 6.71a2.4 2.4 0 0 0 3.191.193" />
|
||||
<circle cx="10.5" cy="6.5" r=".5" fill="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 474 B |
@@ -0,0 +1,510 @@
|
||||
// "This is the wrong record" — looking a disc up by hand, for a CD or a SACD.
|
||||
//
|
||||
// The automatic naming is one guess from one provider, and a DiscID names the
|
||||
// GEOMETRY rather than the pressing: the owner's Fear Inoculum answers with
|
||||
// four releases that share a table of contents. So the list shows the fields
|
||||
// that TELL THEM APART — year, country, label, catalogue number, format and
|
||||
// the track count next to the disc's own — rather than a row of titles that
|
||||
// all read the same.
|
||||
//
|
||||
// Selecting is deliberately not applying: picking a row fetches it in full and
|
||||
// shows its track list, because that list is the only thing that proves a
|
||||
// plausible title is the right pressing.
|
||||
//
|
||||
// Mounted ONCE in AppShell, like TrackReplacementModal: applying RENAMES the
|
||||
// session underneath, so a modal parented into the pane would be torn down by
|
||||
// its own success.
|
||||
//
|
||||
// Data: QbzDiscMeta.metaJson (disc_meta_qt.rs). The only local state is the
|
||||
// query being typed.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
visible: doc.open === true
|
||||
|
||||
readonly property var doc: {
|
||||
try { return JSON.parse(QbzDiscMeta.metaJson) } catch (e) { return ({}) }
|
||||
}
|
||||
readonly property var rows: doc.results || []
|
||||
readonly property var preview: doc.preview || null
|
||||
readonly property bool busy: doc.searching === true || doc.applying === true
|
||||
|
||||
property string queryText: ""
|
||||
onVisibleChanged: if (visible) queryText = root.doc.query || ""
|
||||
|
||||
function tr(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
||||
|
||||
function submit() {
|
||||
var q = root.queryText.trim()
|
||||
if (q !== "" && !root.busy)
|
||||
QbzDiscMeta.search(q)
|
||||
}
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "#bf000000"
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: if (doc.applying !== true) QbzDiscMeta.close()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width - 80, 720)
|
||||
height: Math.min(parent.height * 0.9, 640)
|
||||
radius: theme.radiusLg
|
||||
color: theme.surfaceCard
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
clip: true
|
||||
|
||||
MouseArea { anchors.fill: parent }
|
||||
|
||||
// ---- Header ---------------------------------------------------
|
||||
Item {
|
||||
id: header
|
||||
width: parent.width
|
||||
height: 72
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 24
|
||||
anchors.right: closeX.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.tr("Correct disc details")
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightSemibold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
// Says WHICH medium, because a disc image and the disc in
|
||||
// the drive reach this modal from the same button.
|
||||
text: (root.doc.kind === "sacd"
|
||||
? root.tr("SACD image") : root.tr("Audio CD"))
|
||||
+ " · " + (root.doc.discTrackCount || 0) + " "
|
||||
+ root.tr("tracks")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
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
|
||||
enabled: root.doc.applying !== true
|
||||
onClicked: QbzDiscMeta.close()
|
||||
onContainsMouseChanged: tips.hover(containsMouse, closeX,
|
||||
"meta-close", root.tr("Close without changing anything"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle { y: header.height; width: parent.width; height: 1; color: theme.borderSubtle }
|
||||
|
||||
// ---- Provider + query ------------------------------------------
|
||||
Item {
|
||||
id: bar
|
||||
y: header.height + 1
|
||||
width: parent.width
|
||||
height: 67
|
||||
|
||||
Row {
|
||||
id: providers
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 24
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
Repeater {
|
||||
model: [
|
||||
{ "id": "musicbrainz", "label": "MusicBrainz",
|
||||
"tip": root.tr("Community database. Knows discs by their table of contents.") },
|
||||
{ "id": "discogs", "label": "Discogs",
|
||||
"tip": root.tr("Collector database. Stronger on pressings, editions and catalogue numbers.") }
|
||||
]
|
||||
Rectangle {
|
||||
required property var modelData
|
||||
readonly property bool active: root.doc.provider === modelData.id
|
||||
width: pLabel.width + 24
|
||||
height: 30
|
||||
radius: 6
|
||||
color: active ? theme.accent
|
||||
: (pArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated)
|
||||
Text {
|
||||
id: pLabel
|
||||
anchors.centerIn: parent
|
||||
text: parent.modelData.label
|
||||
color: parent.active ? theme.onAccent : theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
}
|
||||
MouseArea {
|
||||
id: pArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
enabled: !root.busy
|
||||
onClicked: QbzDiscMeta.setProvider(parent.modelData.id)
|
||||
onContainsMouseChanged: tips.hover(containsMouse, parent,
|
||||
"prov-" + parent.modelData.id, parent.modelData.tip)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: providers.right
|
||||
anchors.leftMargin: 12
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 24
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: 34
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceCard
|
||||
border.width: 1
|
||||
border.color: queryInput.activeFocus ? theme.accent : theme.borderSubtle
|
||||
|
||||
TextInput {
|
||||
id: queryInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 40
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontLink
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
clip: true
|
||||
selectByMouse: true
|
||||
enabled: !root.busy
|
||||
text: root.queryText
|
||||
onTextEdited: root.queryText = text
|
||||
onAccepted: root.submit()
|
||||
Binding {
|
||||
target: queryInput
|
||||
property: "text"
|
||||
value: root.queryText
|
||||
when: !queryInput.activeFocus
|
||||
}
|
||||
}
|
||||
Item {
|
||||
id: goBtn
|
||||
width: 28
|
||||
height: 28
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "search"
|
||||
width: 15
|
||||
height: 15
|
||||
visible: root.doc.searching !== true
|
||||
tintName: goArea.containsMouse ? "textPrimary" : "muted"
|
||||
}
|
||||
QbzSpinner {
|
||||
anchors.centerIn: parent
|
||||
size: 15
|
||||
visible: root.doc.searching === true
|
||||
}
|
||||
MouseArea {
|
||||
id: goArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
enabled: !root.busy
|
||||
onClicked: root.submit()
|
||||
onContainsMouseChanged: tips.hover(containsMouse, goBtn,
|
||||
"meta-go", root.tr("Search this provider"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle { y: header.height + bar.height + 1; width: parent.width; height: 1; color: theme.borderSubtle }
|
||||
|
||||
// ---- Results (left) + preview (right) --------------------------
|
||||
Item {
|
||||
y: header.height + bar.height + 2
|
||||
width: parent.width
|
||||
height: card.height - header.height - bar.height - 2 - 66
|
||||
|
||||
// Empty states. Three of them, because "nothing yet", "nothing
|
||||
// found" and "the provider refused" are three different things and
|
||||
// only one of them means the disc is unknown.
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 80
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
visible: root.rows.length === 0 && root.doc.searching !== true
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
text: root.doc.rateLimited === true
|
||||
? root.tr("The provider is rate-limiting right now. Try again in a moment.")
|
||||
: (root.doc.searched === true
|
||||
? root.tr("No releases matched. Try fewer words, or the other provider.")
|
||||
: root.tr("Search for the release this disc actually is."))
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: list
|
||||
width: root.preview ? parent.width * 0.52 : parent.width
|
||||
height: parent.height
|
||||
clip: true
|
||||
model: root.rows
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
delegate: Rectangle {
|
||||
id: row
|
||||
required property var modelData
|
||||
readonly property bool chosen: root.doc.selectedId === modelData.id
|
||||
width: list.width
|
||||
height: 62
|
||||
color: chosen ? theme.surfaceHover
|
||||
: (rowArea.containsMouse ? theme.surfaceElevatedA50 : "transparent")
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 24
|
||||
anchors.right: rowSpin.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Text {
|
||||
width: parent.width
|
||||
text: row.modelData.title
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontLink
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: row.modelData.artist
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
// The line that actually distinguishes four pressings
|
||||
// of one album. A track count that disagrees with the
|
||||
// disc is called out rather than merely shown.
|
||||
Text {
|
||||
width: parent.width
|
||||
text: {
|
||||
var bits = []
|
||||
if (row.modelData.year) bits.push(row.modelData.year)
|
||||
if (row.modelData.country) bits.push(row.modelData.country)
|
||||
if (row.modelData.format) bits.push(row.modelData.format)
|
||||
if (row.modelData.label) bits.push(row.modelData.label)
|
||||
if (row.modelData.catalogNumber) bits.push(row.modelData.catalogNumber)
|
||||
if (row.modelData.trackCount > 0) {
|
||||
var n = row.modelData.trackCount
|
||||
bits.push(n === (root.doc.discTrackCount || 0)
|
||||
? n + " " + root.tr("tracks")
|
||||
: n + " " + root.tr("tracks") + " ⚠")
|
||||
}
|
||||
return bits.join(" · ")
|
||||
}
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
QbzSpinner {
|
||||
id: rowSpin
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 24
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 15
|
||||
visible: root.doc.loadingId === row.modelData.id
|
||||
}
|
||||
MouseArea {
|
||||
id: rowArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
enabled: root.doc.applying !== true
|
||||
onClicked: QbzDiscMeta.select(row.modelData.id)
|
||||
}
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: root.preview !== null
|
||||
x: list.width
|
||||
width: 1
|
||||
height: parent.height
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
|
||||
// The track list of the chosen candidate — the evidence.
|
||||
Flickable {
|
||||
visible: root.preview !== null
|
||||
x: list.width + 1
|
||||
width: parent.width - list.width - 1
|
||||
height: parent.height
|
||||
contentWidth: width
|
||||
contentHeight: prevCol.height + 24
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
Column {
|
||||
id: prevCol
|
||||
x: 16
|
||||
y: 12
|
||||
width: parent.width - 32
|
||||
spacing: 6
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.preview ? root.preview.title : ""
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontLink
|
||||
font.weight: theme.weightSemibold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.preview
|
||||
? (root.preview.artist
|
||||
+ (root.preview.year ? " · " + root.preview.year : ""))
|
||||
: ""
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Item { width: 1; height: 4 }
|
||||
Repeater {
|
||||
model: root.preview ? root.preview.tracks : []
|
||||
Row {
|
||||
required property var modelData
|
||||
width: prevCol.width
|
||||
spacing: 8
|
||||
Text {
|
||||
width: 22
|
||||
text: parent.modelData.number
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
horizontalAlignment: Text.AlignRight
|
||||
}
|
||||
Text {
|
||||
width: parent.width - 30
|
||||
text: parent.modelData.title
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Footer ----------------------------------------------------
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: footerRow.top
|
||||
anchors.bottomMargin: 16
|
||||
height: 1
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
// The escape hatch, only where it can do something. Without it a bad
|
||||
// correction is permanent, which would make people afraid to use the
|
||||
// button at all.
|
||||
Item {
|
||||
id: forgetBtn
|
||||
visible: root.doc.hasCorrection === true
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 24
|
||||
anchors.verticalCenter: footerRow.verticalCenter
|
||||
width: forgetText.width
|
||||
height: 28
|
||||
Text {
|
||||
id: forgetText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.tr("Remove my correction")
|
||||
color: forgetArea.containsMouse ? theme.textPrimary : theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
MouseArea {
|
||||
id: forgetArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzDiscMeta.forget()
|
||||
onContainsMouseChanged: tips.hover(containsMouse, forgetBtn, "meta-forget",
|
||||
root.tr("Forget the saved details for this disc and look it up fresh next time"))
|
||||
}
|
||||
}
|
||||
Row {
|
||||
id: footerRow
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 24
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 16
|
||||
spacing: 12
|
||||
|
||||
SettingsButton {
|
||||
id: cancelBtn
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.tr("Cancel")
|
||||
btnHeight: 34
|
||||
minWidth: 0
|
||||
enabled: root.doc.applying !== true
|
||||
onClicked: QbzDiscMeta.close()
|
||||
HoverHandler {
|
||||
onHoveredChanged: tips.hover(hovered, cancelBtn, "meta-cancel",
|
||||
root.tr("Close without changing anything"))
|
||||
}
|
||||
}
|
||||
QbzPrimaryButton {
|
||||
id: applyBtn
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
btnHeight: 34
|
||||
label: root.doc.applying === true
|
||||
? root.tr("Applying...") : root.tr("Use these details")
|
||||
btnEnabled: root.doc.applying !== true && root.preview !== null
|
||||
onClicked: QbzDiscMeta.apply()
|
||||
// A HoverHandler rather than a MouseArea: this control already
|
||||
// has one, and stacking a second would eat the click.
|
||||
HoverHandler {
|
||||
onHoveredChanged: tips.hover(hovered, applyBtn, "meta-apply",
|
||||
root.tr("Rename this disc and remember it for next time"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QbzTooltip {
|
||||
id: tips
|
||||
anchors.fill: parent
|
||||
z: 900
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,24 @@ Item {
|
||||
root.ownerKey = key
|
||||
}
|
||||
// Race-safe close: only the owner may clear the bubble.
|
||||
/// The whole boilerplate of a hover tooltip, in one call.
|
||||
///
|
||||
/// Every call site was writing the same four lines — an
|
||||
/// `onContainsMouseChanged` that branches on the flag and remembers to
|
||||
/// pass the same key to both `showAbove` and `hide`. Two of them got the
|
||||
/// key wrong, which does not fail: the bubble simply never goes away,
|
||||
/// because `hide` only clears a tooltip it owns. One function, one key,
|
||||
/// no way to mismatch them.
|
||||
///
|
||||
/// `on` is the hover flag (a MouseArea's `containsMouse`, a HoverHandler's
|
||||
/// `hovered`); `item` is what the bubble points at.
|
||||
function hover(on, item, key, text) {
|
||||
if (on)
|
||||
root.showAbove(item, key, text)
|
||||
else
|
||||
root.hide(key)
|
||||
}
|
||||
|
||||
function hide(key) {
|
||||
if (root.ownerKey === key) {
|
||||
root.ownerKey = ""
|
||||
@@ -274,8 +292,17 @@ Item {
|
||||
readonly property bool flipUp: root.isSummary
|
||||
&& (root.belowY + root.bubbleH > root.height - 8)
|
||||
&& (root.aboveY >= 8)
|
||||
// True when the caret points UP at a control above the bubble.
|
||||
readonly property bool caretUp: root.isSummary && !root.flipUp
|
||||
// ...and the mirror of it for the ABOVE placement, which needed the same
|
||||
// escape and did not have it: a control near the TOP of its host — the
|
||||
// disc pane's close button sits 16px under the header — put the bubble at
|
||||
// a negative y, where the header simply painted over it. Flips BELOW when
|
||||
// there is no room above and there is room under.
|
||||
readonly property bool flipDown: root.placement === 1
|
||||
&& (root.aboveY < 8)
|
||||
&& (root.belowY + root.bubbleH <= root.height - 8)
|
||||
// True when the caret points UP at a control above the bubble — i.e.
|
||||
// whenever the bubble ended up UNDER what it describes.
|
||||
readonly property bool caretUp: (root.isSummary && !root.flipUp) || root.flipDown
|
||||
|
||||
// ---- The bubble -------------------------------------------------------
|
||||
Item {
|
||||
@@ -284,7 +311,7 @@ Item {
|
||||
x: Math.round(root.placement === 0 ? root.rightX : root.aboveX)
|
||||
y: Math.round(root.placement === 0 ? root.rightY
|
||||
: root.isSummary ? (root.flipUp ? root.aboveY : root.belowY)
|
||||
: root.aboveY)
|
||||
: (root.flipDown ? root.belowY : root.aboveY))
|
||||
width: Math.ceil(root.bubbleW)
|
||||
height: Math.ceil(root.bubbleH)
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
// What the rip is doing right now.
|
||||
//
|
||||
// The pane's one-line "Ripping 3/7 · 45%" says how far along it is and nothing
|
||||
// else. A rip is minutes of a drive spinning against a disc the user is
|
||||
// holding, and the two questions that line cannot answer are the ones that
|
||||
// matter: which track is being read, and is it safe to take the CD out.
|
||||
//
|
||||
// SOFT LOCK, not a hard one. There is no way to keep a tray shut from here, so
|
||||
// the honest thing is to say it plainly and keep saying it while the drive is
|
||||
// busy — the modal cannot be dismissed into forgetting, because the pane's
|
||||
// indicator reopens it.
|
||||
//
|
||||
// Mounted ONCE in AppShell next to the wizard: it outlives the pane (the user
|
||||
// can navigate away mid-rip and the job keeps going), so it must not be
|
||||
// parented into a view that gets destroyed.
|
||||
//
|
||||
// Opened by QbzShell.ripProgressOpen — QML-side state, because nothing about
|
||||
// "is this panel showing" belongs in Rust.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
readonly property var doc: {
|
||||
try { return JSON.parse(QbzLocal.localRipStatus) } catch (e) { return ({}) }
|
||||
}
|
||||
readonly property bool active: doc.active === true
|
||||
// The flag rides the job's own document (see `rip_qt::Status`): the pane
|
||||
// that opens this panel and the shell that mounts it are in different
|
||||
// subtrees, and the bridge is the only channel both can reach.
|
||||
visible: doc.panelOpen === true
|
||||
|
||||
function tr(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "#bf000000"
|
||||
MouseArea { anchors.fill: parent; onClicked: QbzLocal.ripPanel(false) }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width - 80, 560)
|
||||
height: Math.min(parent.height * 0.85, 560)
|
||||
radius: theme.radiusLg
|
||||
color: theme.surfaceCard
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
clip: true
|
||||
|
||||
MouseArea { anchors.fill: parent }
|
||||
|
||||
// ---- Header -----------------------------------------------------
|
||||
Item {
|
||||
id: header
|
||||
width: parent.width
|
||||
height: 76
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 24
|
||||
anchors.right: closeX.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.doc.album || 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("Writing FLAC files to") + " " + (root.doc.destination || "")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
}
|
||||
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: cArea.containsMouse ? "textPrimary" : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: cArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzLocal.ripPanel(false)
|
||||
// It hides the PANEL, never the job — saying so is the
|
||||
// difference between a close button and a cancel button,
|
||||
// and this one is not a cancel button.
|
||||
onContainsMouseChanged: tips.hover(containsMouse, closeX, "rip-hide",
|
||||
root.tr("Hide this panel — the rip keeps going"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle { y: header.height; width: parent.width; height: 1; color: theme.borderSubtle }
|
||||
|
||||
// ---- The soft lock ----------------------------------------------
|
||||
Rectangle {
|
||||
id: warn
|
||||
y: header.height + 1
|
||||
width: parent.width
|
||||
height: 52
|
||||
color: theme.surfaceElevated
|
||||
visible: root.active
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 24
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 24
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 10
|
||||
QbzIcon {
|
||||
name: "circle-alert"
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "amber"
|
||||
}
|
||||
Text {
|
||||
width: parent.width - 26
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.tr("Ripping — don't eject the disc until this finishes.")
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Overall bar --------------------------------------------------
|
||||
Item {
|
||||
id: overall
|
||||
y: header.height + 1 + (warn.visible ? warn.height : 0)
|
||||
width: parent.width
|
||||
height: 58
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 24
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 24
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
Row {
|
||||
width: parent.width
|
||||
Text {
|
||||
text: root.active
|
||||
? root.tr("Track") + " " + ((root.doc.index || 0) + 1)
|
||||
+ "/" + (root.doc.count || 0)
|
||||
: root.tr("Finished")
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
Item { width: parent.width - 160; height: 1 }
|
||||
Text {
|
||||
width: 160
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: Math.round((root.doc.overall || 0) * 100) + "%"
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 4
|
||||
radius: 2
|
||||
color: theme.surfaceHover
|
||||
Rectangle {
|
||||
width: Math.max(0, Math.min(parent.width,
|
||||
parent.width * (root.doc.overall || 0)))
|
||||
height: parent.height
|
||||
radius: 2
|
||||
color: theme.accent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
y: overall.y + overall.height
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
|
||||
// ---- Per-track list -----------------------------------------------
|
||||
ListView {
|
||||
y: overall.y + overall.height + 1
|
||||
width: parent.width
|
||||
height: card.height - y - (footer.visible ? footer.height : 0)
|
||||
clip: true
|
||||
model: root.doc.tracks || []
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
delegate: Item {
|
||||
id: trackRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
// done | current | pending — derived from ONE number rather
|
||||
// than published per row, so the list can never disagree with
|
||||
// the bar above it.
|
||||
readonly property string state: index < (root.doc.index || 0)
|
||||
? "done"
|
||||
: (index === (root.doc.index || 0) && root.active ? "current" : "pending")
|
||||
width: ListView.view.width
|
||||
height: 44
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 24
|
||||
anchors.rightMargin: 24
|
||||
spacing: 10
|
||||
|
||||
Item {
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
QbzIcon {
|
||||
anchors.fill: parent
|
||||
visible: trackRow.state === "done"
|
||||
name: "circle-check-big"
|
||||
tintName: "accent"
|
||||
}
|
||||
QbzSpinner {
|
||||
anchors.centerIn: parent
|
||||
size: 14
|
||||
visible: trackRow.state === "current"
|
||||
}
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: trackRow.state === "pending"
|
||||
text: trackRow.modelData.number
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
}
|
||||
Column {
|
||||
width: parent.width - 26 - 54
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 4
|
||||
Text {
|
||||
width: parent.width
|
||||
text: trackRow.modelData.title
|
||||
color: trackRow.state === "pending"
|
||||
? theme.textMuted : theme.textPrimary
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
// The per-track bar, on the CURRENT row only. A row of
|
||||
// seven bars all sitting at 0% or 100% is noise; the
|
||||
// one that is moving is the one worth drawing.
|
||||
Rectangle {
|
||||
visible: trackRow.state === "current"
|
||||
width: parent.width
|
||||
height: 3
|
||||
radius: 2
|
||||
color: theme.surfaceHover
|
||||
Rectangle {
|
||||
width: Math.max(0, Math.min(parent.width,
|
||||
parent.width * (root.doc.fraction || 0)))
|
||||
height: parent.height
|
||||
radius: 2
|
||||
color: theme.accent
|
||||
}
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: 44
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
horizontalAlignment: Text.AlignRight
|
||||
visible: trackRow.state === "current"
|
||||
text: Math.round((root.doc.fraction || 0) * 100) + "%"
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Stop --------------------------------------------------------
|
||||
//
|
||||
// It stops the JOB and deletes nothing, and both halves are said out
|
||||
// loud. A "cancel" that silently removed four finished tracks would
|
||||
// throw away minutes the user already waited for, and a "cancel" that
|
||||
// left them without saying so is a folder they later find and cannot
|
||||
// explain.
|
||||
Item {
|
||||
id: footer
|
||||
width: parent.width
|
||||
height: 84
|
||||
anchors.bottom: parent.bottom
|
||||
visible: root.active
|
||||
|
||||
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 24
|
||||
anchors.right: stopBtn.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
wrapMode: Text.WordWrap
|
||||
text: root.tr("Files already written stay on disk. Delete them yourself if you don't want them.")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
SettingsButton {
|
||||
id: stopBtn
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 24
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
btnHeight: 34
|
||||
minWidth: 0
|
||||
enabled: root.doc.cancelling !== true
|
||||
text: root.doc.cancelling === true
|
||||
? root.tr("Stopping…") : root.tr("Stop ripping")
|
||||
onClicked: QbzLocal.ripCancel()
|
||||
HoverHandler {
|
||||
onHoveredChanged: tips.hover(hovered, stopBtn, "rip-stop",
|
||||
root.tr("Stop after the current track. Nothing is deleted."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QbzTooltip {
|
||||
id: tips
|
||||
anchors.fill: parent
|
||||
z: 900
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// A checkbox for the rip wizard's track list.
|
||||
//
|
||||
// Its own file rather than a copy per call site, and NOT the app's other
|
||||
// tick-shaped controls: the multi-select bar's is a row-hover affordance and
|
||||
// the settings toggles are switches. This one is a plain, always-visible
|
||||
// checkbox with a THIRD state — partially selected — which the select-all row
|
||||
// needs and neither of the others has.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
property bool checked: false
|
||||
/// Some but not all — drawn as a dash, the convention every file manager
|
||||
/// uses, because a half-ticked box that looks empty invites a click that
|
||||
/// does the opposite of what the user expects.
|
||||
property bool partial: false
|
||||
signal toggled()
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
width: 18
|
||||
height: 18
|
||||
radius: 4
|
||||
color: (root.checked || root.partial) ? theme.accent : "transparent"
|
||||
border.width: 1
|
||||
border.color: (root.checked || root.partial)
|
||||
? theme.accent
|
||||
: (area.containsMouse ? theme.textMuted : theme.borderStrong)
|
||||
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
visible: root.checked && !root.partial
|
||||
name: "check"
|
||||
width: 12
|
||||
height: 12
|
||||
// The glyph sits on `accent`, so it takes the measured on-accent
|
||||
// selector rather than a hardcoded white — 16 of the 35 shipped
|
||||
// palettes make white unreadable there (QbzCircleAction's rationale).
|
||||
tintName: theme.accentGlyphTint
|
||||
}
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
visible: root.partial
|
||||
width: 9
|
||||
height: 2
|
||||
radius: 1
|
||||
// The COLOUR twin of the tint above, so the dash and a check on
|
||||
// the same palette can never be different colours.
|
||||
color: theme.accentGlyphColor
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: area
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggled()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
// 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 || ""
|
||||
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() }
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
// ---- 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
|
||||
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
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,17 @@ Rectangle {
|
||||
property bool showDownload: false
|
||||
property bool downloadGlyph: false
|
||||
property bool showMenu: true
|
||||
/// The row is EPHEMERAL — a disc, a disc image or an ad-hoc folder.
|
||||
///
|
||||
/// It has no `library.db` row and no catalog id, so every entry that
|
||||
/// writes a reference meant to outlive the session (favourite, playlist,
|
||||
/// mixtape, offline cache, share) has nothing to point at once the disc
|
||||
/// comes out. The QUEUE block stays, because a queue is exactly as
|
||||
/// temporary as the session is.
|
||||
///
|
||||
/// Entries are ABSENT rather than rendered-and-inert — the same rule the
|
||||
/// `has*Seam` gates in `menuModel` already follow.
|
||||
property bool queueOnly: false
|
||||
property bool zebra: false
|
||||
property bool artistLink: false
|
||||
property bool clickPlays: true
|
||||
@@ -241,7 +252,7 @@ Rectangle {
|
||||
// there is no track to copy anywhere.
|
||||
//
|
||||
// Ephemeral rows never reach this decision: their surface sets
|
||||
// `showMenu: false`, so the whole menu is absent (LocalEphemeralPane).
|
||||
// `queueOnly: true`, so `menuModel` returns after the transport block.
|
||||
// An ALLOWLIST of non-catalog words, not `!== "qobuz"`: "offline" is an
|
||||
// offline-CACHED Qobuz row whose id IS a catalog id, so inverting the test
|
||||
// would strip Add-to-playlist from the rows that legitimately have it.
|
||||
@@ -1151,6 +1162,11 @@ Rectangle {
|
||||
m.push({ "label": t("Play later", r), "icon": "list-plus", "action": "later" })
|
||||
m.push({ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" })
|
||||
}
|
||||
// An ephemeral row stops here: everything below writes something that
|
||||
// is supposed to survive the session, and there will be nothing left
|
||||
// to resolve.
|
||||
if (root.queueOnly)
|
||||
return m
|
||||
// THE REPAIR. First entry on a dead row, because it is the only reason
|
||||
// that row's menu is worth opening at all. Host-routed — see
|
||||
// `routeReplaceExternally`.
|
||||
|
||||
@@ -720,6 +720,23 @@ Rectangle {
|
||||
TrackReplacementModal {
|
||||
anchors.fill: parent
|
||||
}
|
||||
// The two DISC modals. Out here for the same reason as their neighbours:
|
||||
// both of them RENAME the open session as their success case, so a modal
|
||||
// parented into the Local Library pane would be torn down by the very
|
||||
// publish that means it worked. Each self-gates on its own document, so a
|
||||
// closed one is an invisible, non-interactive Item.
|
||||
DiscMetaModal {
|
||||
anchors.fill: parent
|
||||
}
|
||||
RipWizardModal {
|
||||
anchors.fill: parent
|
||||
}
|
||||
// The rip's progress panel. Out here because the JOB outlives the pane —
|
||||
// the user can navigate away mid-rip and the drive keeps spinning — so it
|
||||
// must not be parented into a view that gets destroyed on a tab change.
|
||||
RipProgressModal {
|
||||
anchors.fill: parent
|
||||
}
|
||||
MyQbzModals {
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ Rectangle {
|
||||
/// Favorite state of the now-playing track (QueueState.now-playing-favorite
|
||||
/// in Slint) — the host bar already parses the queue document.
|
||||
property bool favorite: false
|
||||
/// The playing track leaves no trace in QBZ (a disc, an image, an ad-hoc
|
||||
/// folder). There is nothing a favourite could point at once the session
|
||||
/// ends, so the heart goes OFF rather than writing a row that will dangle.
|
||||
/// Dimmed and inert in place, not hidden: the transport keeps its shape
|
||||
/// while a disc plays.
|
||||
property bool disabled: false
|
||||
|
||||
readonly property bool actionable: QbzPlayer.npHasTrack && !root.disabled
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
@@ -28,8 +36,8 @@ Rectangle {
|
||||
width: 32
|
||||
height: 32
|
||||
radius: theme.radiusSm
|
||||
opacity: QbzPlayer.npHasTrack ? 1.0 : 0.3
|
||||
color: (favArea.containsMouse && QbzPlayer.npHasTrack) ? theme.surfaceHover : "transparent"
|
||||
opacity: root.actionable ? 1.0 : 0.3
|
||||
color: (favArea.containsMouse && root.actionable) ? theme.surfaceHover : "transparent"
|
||||
|
||||
QbzIcon {
|
||||
name: root.favorite ? "heart-filled" : "heart"
|
||||
@@ -43,8 +51,8 @@ Rectangle {
|
||||
id: favArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: QbzPlayer.npHasTrack ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: if (QbzPlayer.npHasTrack && QbzPlayer.npTrackId !== "")
|
||||
cursorShape: root.actionable ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: if (root.actionable && QbzPlayer.npTrackId !== "")
|
||||
QbzQueue.queueToggleFavorite("track", QbzPlayer.npTrackId)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,19 @@ Rectangle {
|
||||
// without it the bar kept a stale glyph until the track changed.
|
||||
property string favOverrideId: ""
|
||||
property bool favOverrideValue: false
|
||||
/// The playing track is EPHEMERAL — a disc, an image or an ad-hoc folder.
|
||||
/// Same flag and same rule as the full bar: nothing that implies
|
||||
/// persistence is offered, because there is nothing to point at once the
|
||||
/// session ends. `queue_qt.rs` publishes it on every row.
|
||||
readonly property bool npEphemeral: {
|
||||
try {
|
||||
var d = JSON.parse(QbzQueue.queueJson)
|
||||
return !!(d && d.current && d.current.isEphemeral)
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
readonly property bool npFavorite: {
|
||||
if (root.favOverrideId !== "" && root.favOverrideId === QbzPlayer.npTrackId)
|
||||
return root.favOverrideValue
|
||||
@@ -367,6 +380,7 @@ Rectangle {
|
||||
height: 34
|
||||
playCircle: false
|
||||
favorite: root.npFavorite
|
||||
ephemeral: root.npEphemeral
|
||||
onAddRequested: function (anchorItem) { smallAddMenu.openBelowRight(anchorItem) }
|
||||
onTrackInfoRequested: root.openTrackInfo()
|
||||
}
|
||||
@@ -593,18 +607,23 @@ Rectangle {
|
||||
id: smallAddMenu
|
||||
menuWidth: 232
|
||||
entries: {
|
||||
var m = [
|
||||
{
|
||||
// The three QUEUE actions are the only ones an ephemeral track can
|
||||
// honour; the rest write a reference that outlives the session.
|
||||
var m = []
|
||||
if (!root.npEphemeral) {
|
||||
m.push({
|
||||
"label": root.npFavorite
|
||||
? QbzSession.tr("Remove from library", QbzSession.trRev)
|
||||
: QbzSession.tr("Add to library", QbzSession.trRev),
|
||||
"icon": root.npFavorite ? "heart-filled" : "heart",
|
||||
"action": "favorite"
|
||||
},
|
||||
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
|
||||
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
|
||||
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
|
||||
]
|
||||
})
|
||||
}
|
||||
m.push({ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" })
|
||||
m.push({ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" })
|
||||
m.push({ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" })
|
||||
if (root.npEphemeral)
|
||||
return m
|
||||
// "Add to playlist" is SECOND (TransportControls.slint:143 — the
|
||||
// full bar carries the rationale). Same `npSource` gate as the
|
||||
// mixtape entry: the picker's Qobuz arm takes catalog ids and an
|
||||
|
||||
@@ -159,6 +159,28 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
/// The playing track is EPHEMERAL — a disc, an image or an ad-hoc folder.
|
||||
///
|
||||
/// It leaves no trace in QBZ: no `library.db` row, no catalog id, nothing
|
||||
/// a favourite or a playlist entry could point at tomorrow. So every
|
||||
/// affordance that implies PERSISTENCE has to go, and the queue ones stay
|
||||
/// — which is exactly what `QueueSidebar` already does with the same flag
|
||||
/// (`queue_qt.rs` publishes `isEphemeral` on every row; this bar simply
|
||||
/// never read it).
|
||||
///
|
||||
/// Read from the queue's `current` row rather than guessed from the id
|
||||
/// range: Rust tests BOTH halves there (the row's own source tag and the
|
||||
/// id floor), and a second, weaker copy of that test in QML is a second
|
||||
/// thing to get wrong.
|
||||
readonly property bool npEphemeral: {
|
||||
try {
|
||||
var d = JSON.parse(QbzQueue.queueJson)
|
||||
return !!(d && d.current && d.current.isEphemeral)
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SOURCE of the now-playing track — the word the MyQBZ AddItem payload
|
||||
// needs ("qobuz" | "local"), NEVER a literal.
|
||||
//
|
||||
@@ -403,6 +425,7 @@ Rectangle {
|
||||
playCircle: false
|
||||
classicActions: true
|
||||
favorite: root.npFavorite
|
||||
ephemeral: root.npEphemeral
|
||||
onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) }
|
||||
onTrackInfoRequested: root.openTrackInfo()
|
||||
}
|
||||
@@ -640,18 +663,26 @@ Rectangle {
|
||||
id: addMenu
|
||||
menuWidth: 232
|
||||
entries: {
|
||||
var m = [
|
||||
{
|
||||
// The three QUEUE actions are the only ones an ephemeral track can
|
||||
// honour: everything else here writes a reference that outlives the
|
||||
// session, and there is nothing to reference once the disc comes
|
||||
// out. They are ABSENT rather than rendered-and-inert, the same
|
||||
// rule the `npSource` gates below already follow.
|
||||
var m = []
|
||||
if (!root.npEphemeral) {
|
||||
m.push({
|
||||
"label": root.npFavorite
|
||||
? QbzSession.tr("Remove from library", QbzSession.trRev)
|
||||
: QbzSession.tr("Add to library", QbzSession.trRev),
|
||||
"icon": root.npFavorite ? "heart-filled" : "heart",
|
||||
"action": "favorite"
|
||||
},
|
||||
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
|
||||
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
|
||||
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
|
||||
]
|
||||
})
|
||||
}
|
||||
m.push({ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" })
|
||||
m.push({ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" })
|
||||
m.push({ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" })
|
||||
if (root.npEphemeral)
|
||||
return m
|
||||
// "Add to playlist" sits SECOND in TransportControls.slint:143 —
|
||||
// spliced in there rather than appended, because the flyout's
|
||||
// order is part of the parity. It rides the SAME `npSource` gate
|
||||
|
||||
@@ -75,6 +75,13 @@ Rectangle {
|
||||
QbzArtist.openArtist(root.ctxId)
|
||||
} else if (root.ctxKind === "playlist") {
|
||||
QbzBridge.openPlaylist(root.ctxId)
|
||||
} else if (root.ctxKind === "ephemeral") {
|
||||
// A disc or an ad-hoc folder. Its "album" is a synthetic grouping
|
||||
// key (`cdda|||Fear Inoculum`) that no catalogue can open, so the
|
||||
// origin is the SESSION and the place that shows it is the Open
|
||||
// pane's own tab in Local Library.
|
||||
QbzLocal.setPendingRoute('{"tab":"ephemeral"}')
|
||||
QbzShell.navigateTo("locallibrary")
|
||||
} else if (root.ctxKind === "album") {
|
||||
QbzAlbum.openAlbum(root.ctxId)
|
||||
} else if (QbzPlayer.npAlbumId !== "") {
|
||||
|
||||
@@ -23,6 +23,8 @@ Row {
|
||||
property bool classicActions: false
|
||||
/// Now-playing favorite state, for the Classic heart.
|
||||
property bool favorite: false
|
||||
/// The playing track is ephemeral — the heart has nothing to write to.
|
||||
property bool ephemeral: false
|
||||
|
||||
/// Emitted by "+" with the button as anchor, so the flyout is owned
|
||||
/// by the bar (one menu, both mount points).
|
||||
@@ -172,6 +174,7 @@ Row {
|
||||
FavToggle {
|
||||
visible: tc.classicActions
|
||||
favorite: tc.favorite
|
||||
disabled: tc.ephemeral
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
@@ -974,9 +974,22 @@ Rectangle {
|
||||
height: parent.height - 91
|
||||
clip: true
|
||||
|
||||
// The `Open` pane is showing. It is the ONE body that renders with
|
||||
// no indexed library (see the Loader below), so it is also the one
|
||||
// the empty state has to stand down for.
|
||||
readonly property bool ephemeralShowing:
|
||||
root.activeTab === "ephemeral" && root.ephemeralActive
|
||||
|
||||
// "Nothing indexed yet" (no db / no registered folder).
|
||||
//
|
||||
// ALSO gated on the Open pane, and that second half is not
|
||||
// hypothetical: on a machine with no library at all — a fresh
|
||||
// install, or the owner's Mac mini — opening a CD drew the disc
|
||||
// AND this empty state through it, both fully painted, because
|
||||
// each was individually correct about its own condition. Reported
|
||||
// as a transparency bug; it was two views agreeing to be visible.
|
||||
QbzEmptyState {
|
||||
visible: !QbzLocal.localAvailable
|
||||
visible: !QbzLocal.localAvailable && !contentArea.ephemeralShowing
|
||||
anchors.centerIn: parent
|
||||
iconName: "folder-plus"
|
||||
title: QbzSession.tr("No local library yet", QbzSession.trRev)
|
||||
@@ -1027,7 +1040,7 @@ Rectangle {
|
||||
// exactly the people the feature exists for.
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: root.activeTab === "ephemeral" && root.ephemeralActive
|
||||
active: contentArea.ephemeralShowing
|
||||
sourceComponent: LocalEphemeralPane { view: root }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +52,15 @@ Item {
|
||||
// Folder management, scan and maintenance do NOT live in this
|
||||
// view (Slint header note) — the gear routes out.
|
||||
QbzNavButton {
|
||||
id: gearBtn
|
||||
name: "settings-2"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzShell.navigateTo("settings")
|
||||
HoverHandler {
|
||||
onHoveredChanged: tips.hover(hovered, gearBtn, "local-gear",
|
||||
QbzSession.tr("Manage the folders your library is built from",
|
||||
QbzSession.trRev))
|
||||
}
|
||||
}
|
||||
// ---- Refresh ▾ -------------------------------------------
|
||||
// Before Open, per the owner's layout: the two maintenance glyphs
|
||||
@@ -73,6 +79,11 @@ Item {
|
||||
// start a DIFFERENT source's sync (they are independent
|
||||
// jobs on independent stores).
|
||||
onClicked: refreshMenu.openBelowLeft(refreshBtn)
|
||||
HoverHandler {
|
||||
onHoveredChanged: tips.hover(hovered, refreshBtn, "refresh-chip",
|
||||
QbzSession.tr("Re-scan your folders and media servers for new music",
|
||||
QbzSession.trRev))
|
||||
}
|
||||
}
|
||||
QbzSpinner {
|
||||
// ANY sync in flight, not just Plex's: the media-server
|
||||
@@ -94,7 +105,15 @@ Item {
|
||||
width: openRow.width + 20
|
||||
height: 30
|
||||
radius: 6
|
||||
color: openArea.containsMouse || openMenu.opened
|
||||
|
||||
// An open is in flight. Spinning a drive up and reading a TOC
|
||||
// takes SECONDS, and until this existed those seconds looked
|
||||
// exactly like a click that missed — so the chip both says so
|
||||
// and stops taking a second click onto a drive it is already
|
||||
// reading.
|
||||
readonly property bool busy: QbzLocal.localDiscOpening
|
||||
|
||||
color: (!openBtn.busy && (openArea.containsMouse || openMenu.opened))
|
||||
? theme.surfaceHover
|
||||
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
|
||||
@@ -102,12 +121,27 @@ Item {
|
||||
id: openRow
|
||||
anchors.centerIn: parent
|
||||
spacing: 6
|
||||
QbzIcon {
|
||||
name: "folder-open"
|
||||
// The spinner takes the ICON's slot rather than being
|
||||
// appended: the chip is 15px wider either way, so the
|
||||
// label and the chevron do not shuffle sideways the
|
||||
// instant the disc is picked.
|
||||
Item {
|
||||
width: 15
|
||||
height: 15
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: openArea.containsMouse ? "textPrimary" : "secondary"
|
||||
QbzIcon {
|
||||
anchors.fill: parent
|
||||
name: "folder-open"
|
||||
visible: !openBtn.busy
|
||||
tintName: openArea.containsMouse ? "textPrimary" : "secondary"
|
||||
}
|
||||
QbzSpinner {
|
||||
anchors.centerIn: parent
|
||||
size: 15
|
||||
// `visible` also stops the rotation (QbzSpinner
|
||||
// gates on it), so an idle chip presents nothing.
|
||||
visible: openBtn.busy
|
||||
}
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -127,9 +161,18 @@ Item {
|
||||
MouseArea {
|
||||
id: openArea
|
||||
anchors.fill: parent
|
||||
enabled: !openBtn.busy
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: openMenu.openBelowLeft(openBtn)
|
||||
// What this door is FOR, in one line. "Open" next to a tab
|
||||
// called Folders reads as "browse my library", which is
|
||||
// the opposite of what it does.
|
||||
onContainsMouseChanged: tips.hover(containsMouse, openBtn, "open-chip",
|
||||
openBtn.busy
|
||||
? QbzSession.tr("Reading the disc…", QbzSession.trRev)
|
||||
: QbzSession.tr("Play a folder, CD or disc image without adding it to your library",
|
||||
QbzSession.trRev))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,4 +302,13 @@ Item {
|
||||
QbzLocal.mediaSync("subsonic", false)
|
||||
}
|
||||
}
|
||||
|
||||
// Hover tooltips for this header's controls. The overlay takes no pointer
|
||||
// and owns no animator, so an idle one costs nothing.
|
||||
QbzTooltip {
|
||||
id: tips
|
||||
anchors.fill: parent
|
||||
z: 900
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,7 +21,54 @@ Item {
|
||||
QbzTheme { id: theme }
|
||||
|
||||
readonly property var eph: view ? view.ephemeral : null
|
||||
readonly property var albums: eph && eph.albums ? eph.albums : []
|
||||
readonly property var allAlbums: eph && eph.albums ? eph.albums : []
|
||||
|
||||
/// A CLIENT-side filter over the session that is already on screen.
|
||||
///
|
||||
/// It exists because a box set is not a browsable list: the owner's Saint
|
||||
/// Seiya Eternal CD-Box is 247 tracks, and reaching one of them meant
|
||||
/// scrolling past the other 246. Nothing here asks Rust for anything — the
|
||||
/// whole session is already in this document, so filtering it is a pass
|
||||
/// over an array the pane is holding anyway.
|
||||
property string query: ""
|
||||
readonly property var albums: {
|
||||
var q = root.query.trim().toLowerCase()
|
||||
if (q === "")
|
||||
return root.allAlbums
|
||||
var out = []
|
||||
for (var i = 0; i < root.allAlbums.length; i++) {
|
||||
var a = root.allAlbums[i]
|
||||
var src = a.tracks || []
|
||||
// A block whose ALBUM matches keeps ALL of its tracks: searching
|
||||
// for a record means wanting the record, not only the rows whose
|
||||
// titles happen to repeat its name.
|
||||
var albumHit = (a.title || "").toLowerCase().indexOf(q) !== -1
|
||||
|| (a.artist || "").toLowerCase().indexOf(q) !== -1
|
||||
var hits = []
|
||||
if (albumHit) {
|
||||
hits = src
|
||||
} else {
|
||||
for (var j = 0; j < src.length; j++) {
|
||||
var t = src[j]
|
||||
if ((t.title || "").toLowerCase().indexOf(q) !== -1
|
||||
|| (t.artist || "").toLowerCase().indexOf(q) !== -1)
|
||||
hits.push(t)
|
||||
}
|
||||
}
|
||||
if (hits.length === 0)
|
||||
continue
|
||||
out.push({ "groupKey": a.groupKey, "title": a.title, "artist": a.artist,
|
||||
"meta": a.meta, "isCue": a.isCue, "artKey": a.artKey,
|
||||
"tracks": hits })
|
||||
}
|
||||
return out
|
||||
}
|
||||
readonly property int matchCount: {
|
||||
var n = 0
|
||||
for (var i = 0; i < root.albums.length; i++)
|
||||
n += (root.albums[i].tracks || []).length
|
||||
return n
|
||||
}
|
||||
|
||||
/// Art for the 224px header cell: the FIRST block's key. For the common
|
||||
/// one-album session that is simply "the cover"; for a multi-album folder
|
||||
@@ -29,7 +76,7 @@ Item {
|
||||
/// below. Empty when the session has no art at all, which collapses the
|
||||
/// cell instead of reserving an empty square.
|
||||
readonly property string headerArtKey:
|
||||
albums.length > 0 && albums[0].artKey ? albums[0].artKey : ""
|
||||
allAlbums.length > 0 && allAlbums[0].artKey ? allAlbums[0].artKey : ""
|
||||
|
||||
// Same rule as LocalFolderDetail: the host reports this pane's covers as
|
||||
// one window when the document changes, so re-opening the pane on an
|
||||
@@ -129,6 +176,28 @@ Item {
|
||||
font.weight: theme.weightBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
// The artist, in LocalAlbumHeader's own shape (fontHeading,
|
||||
// bold, text-secondary) — for a one-album session this pane
|
||||
// IS an album page, and the two must not present the same
|
||||
// record differently.
|
||||
//
|
||||
// NOT clickable, unlike the album header's: that one routes
|
||||
// into the Artists tab, and the act on a CD the library has
|
||||
// never seen is a route to an empty page. A disc names its
|
||||
// artist; it does not promise the library knows them.
|
||||
//
|
||||
// Hidden rather than blank when there is none: an
|
||||
// unidentified disc has no artist to state, and an empty
|
||||
// bold line reads as a loading failure.
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: text !== ""
|
||||
text: root.allAlbums.length > 0 ? (root.allAlbums[0].artist || "") : ""
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
// "10 tracks · 42 min". The PATH used to live here and no
|
||||
// longer does: the medium's name is already the line above
|
||||
// and the tab, and the duration is what a listener actually
|
||||
@@ -145,45 +214,116 @@ Item {
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
// Every control here is a bare glyph, and three of them do
|
||||
// things that are not guessable from a circle with an icon
|
||||
// in it — so each one says what it does on hover.
|
||||
Row {
|
||||
spacing: 12
|
||||
topPadding: 4
|
||||
QbzCircleAction {
|
||||
id: playAllBtn
|
||||
name: "play-fill"
|
||||
primary: true
|
||||
onClicked: QbzLocal.ephemeralPlayAll(false)
|
||||
HoverHandler {
|
||||
onHoveredChanged: tips.hover(hovered, playAllBtn, "eph-play",
|
||||
QbzSession.tr("Play the whole disc from the first track",
|
||||
QbzSession.trRev))
|
||||
}
|
||||
}
|
||||
QbzCircleAction {
|
||||
id: shuffleBtn
|
||||
name: "shuffle"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzLocal.ephemeralPlayAll(true)
|
||||
HoverHandler {
|
||||
onHoveredChanged: tips.hover(hovered, shuffleBtn, "eph-shuffle",
|
||||
QbzSession.tr("Play it in a shuffled order", QbzSession.trRev))
|
||||
}
|
||||
}
|
||||
// Fix the names. Offered for both DISC media — a
|
||||
// CD-DA carries no titles at all, and a SACD's Master
|
||||
// TOC can carry the wrong ones ("names itself" and
|
||||
// "names itself correctly" are different claims) — and
|
||||
// for NEITHER on an opened folder, which has no disc
|
||||
// identity to write a correction under.
|
||||
//
|
||||
// `tags`, not a magnifier: the button edits this
|
||||
// record's metadata. The search is only how it gets
|
||||
// there, and a magnifier next to a search field that
|
||||
// filters tracks reads as the same control twice.
|
||||
QbzCircleAction {
|
||||
id: metaBtn
|
||||
visible: QbzLocal.localSessionIsDisc && !QbzLocal.localRipActive
|
||||
name: "tags"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzDiscMeta.open()
|
||||
HoverHandler {
|
||||
onHoveredChanged: tips.hover(hovered, metaBtn, "eph-meta",
|
||||
QbzSession.tr("Look this disc up and correct its details",
|
||||
QbzSession.trRev))
|
||||
}
|
||||
}
|
||||
// Rip — a PHYSICAL disc only. An image is already a
|
||||
// file, so offering it there would be offering to copy
|
||||
// something the user already has.
|
||||
QbzCircleAction {
|
||||
id: ripBtn
|
||||
visible: QbzLocal.localEphemeralIsCd && !QbzLocal.localRipActive
|
||||
name: "disc-folder"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzLocal.ripDisc()
|
||||
onClicked: QbzLocal.ripWizardOpen()
|
||||
HoverHandler {
|
||||
onHoveredChanged: tips.hover(hovered, ripBtn, "eph-rip",
|
||||
QbzSession.tr("Copy this CD to your computer as FLAC files",
|
||||
QbzSession.trRev))
|
||||
}
|
||||
}
|
||||
// While it runs the button gives way to its progress —
|
||||
// one control, one state, rather than a button that
|
||||
// looks pressable during a job it cannot start twice.
|
||||
Row {
|
||||
//
|
||||
// And the indicator is a DOOR, not a label: it is the
|
||||
// only thing on screen while a rip runs, and the two
|
||||
// questions it cannot answer — which track, and is it
|
||||
// safe to eject — are the ones worth opening a panel
|
||||
// for.
|
||||
Rectangle {
|
||||
id: ripChip
|
||||
visible: QbzLocal.localRipActive
|
||||
spacing: 8
|
||||
width: ripRow.width + 20
|
||||
height: 32
|
||||
radius: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
QbzSpinner {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 15
|
||||
color: ripArea.containsMouse
|
||||
? theme.surfaceHover
|
||||
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
Row {
|
||||
id: ripRow
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
QbzSpinner {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 15
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Ripping", QbzSession.trRev)
|
||||
+ " " + QbzLocal.localRipProgress
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Ripping", QbzSession.trRev)
|
||||
+ " " + QbzLocal.localRipProgress
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
MouseArea {
|
||||
id: ripArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzLocal.ripPanel(true)
|
||||
onContainsMouseChanged: tips.hover(containsMouse, ripChip,
|
||||
"rip-open",
|
||||
QbzSession.tr("See what the rip is doing, or stop it",
|
||||
QbzSession.trRev))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,7 +345,7 @@ Item {
|
||||
|
||||
// Empty (no playable tracks).
|
||||
Text {
|
||||
visible: !QbzLocal.localEphemeralLoading && root.albums.length === 0
|
||||
visible: !QbzLocal.localEphemeralLoading && root.allAlbums.length === 0
|
||||
width: parent.width
|
||||
topPadding: 24
|
||||
text: QbzSession.tr("No playable tracks in this folder.", QbzSession.trRev)
|
||||
@@ -214,6 +354,54 @@ Item {
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
// ---- Filter ----
|
||||
//
|
||||
// Above the blocks rather than in the header: it acts on the LIST,
|
||||
// and a control that sits with the play buttons reads as another
|
||||
// thing you do to the album. Only drawn once there is enough to be
|
||||
// worth filtering — on a seven-track CD it would be furniture.
|
||||
Item {
|
||||
visible: !QbzLocal.localEphemeralLoading && root.eph
|
||||
&& (root.eph.trackCount || 0) > 12
|
||||
width: parent.width
|
||||
height: visible ? 40 : 0
|
||||
|
||||
QbzLineEdit {
|
||||
id: filterBox
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 260
|
||||
sm: true
|
||||
searchMode: true
|
||||
placeholder: QbzSession.tr("Filter tracks", QbzSession.trRev)
|
||||
text: root.query
|
||||
onEdited: function (v) { root.query = v }
|
||||
}
|
||||
Text {
|
||||
anchors.right: filterBox.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.query.trim() !== ""
|
||||
text: root.matchCount + " / " + (root.eph ? (root.eph.trackCount || 0) : 0)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing matched. Distinct from "no playable tracks": the session
|
||||
// is fine, the filter is what is empty, and saying so is what
|
||||
// stops it reading as a folder that failed to scan.
|
||||
Text {
|
||||
visible: !QbzLocal.localEphemeralLoading
|
||||
&& root.allAlbums.length > 0 && root.albums.length === 0
|
||||
width: parent.width
|
||||
topPadding: 24
|
||||
text: QbzSession.tr("No track matches that.", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontBody
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
// ---- Album blocks ----
|
||||
Column {
|
||||
visible: !QbzLocal.localEphemeralLoading
|
||||
@@ -344,7 +532,13 @@ Item {
|
||||
showAlbum: false
|
||||
showFavorite: false
|
||||
showDownload: false
|
||||
showMenu: false
|
||||
// The menu is BACK, but only its queue block.
|
||||
// A disc track can be queued, played next and
|
||||
// played later; it cannot be favourited, put
|
||||
// in a playlist or cached, because none of
|
||||
// those references survive the eject.
|
||||
showMenu: true
|
||||
queueOnly: true
|
||||
draggable: false
|
||||
zebra: true
|
||||
onPlayRequested: QbzLocal.ephemeralPlayTrack(modelData.id)
|
||||
@@ -365,12 +559,26 @@ Item {
|
||||
// OUTSIDE the Flickable on purpose: scrolling a long disc must not carry
|
||||
// the only way to close the session off the top of the screen.
|
||||
QbzCircleAction {
|
||||
id: closeSession
|
||||
name: "x"
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.rightMargin: 32
|
||||
anchors.topMargin: 16
|
||||
onClicked: QbzLocal.ephemeralClear()
|
||||
HoverHandler {
|
||||
onHoveredChanged: tips.hover(hovered, closeSession, "eph-close",
|
||||
QbzSession.tr("Close this disc and go back to your library",
|
||||
QbzSession.trRev))
|
||||
}
|
||||
}
|
||||
|
||||
// The pane's own tooltip overlay. It takes no pointer and owns no
|
||||
// animator, so an idle one costs nothing (QbzTooltip's header).
|
||||
QbzTooltip {
|
||||
id: tips
|
||||
anchors.fill: parent
|
||||
z: 900
|
||||
}
|
||||
|
||||
QbzScrollBar {
|
||||
|
||||
Reference in new issue
Block a user