refactor(metadata): split editor workspace

Give track editing a dense spreadsheet pane, move album tags to an independent sidebar, and isolate remote metadata and artwork lookup in a loading-aware popup.

cx
This commit is contained in:
vicrodh committed 2026-08-24 21:37:11 -06:00
1 parent fa649394d1
commit 5e759fab50
4 files changed
+1055 -787

No files matched your search

+5 -783
View File
@@ -326,791 +326,13 @@ Item {
visible: QbzTagEditor.editorLoading || !root.seeded
}
Flickable {
id: scroll
visible: root.seeded && !QbzTagEditor.editorLoading
TagEditorWorkspace {
anchors.fill: parent
anchors.margins: 20
contentWidth: width
contentHeight: content.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: scroll.width - 14
spacing: 18
Text {
text: root.tr("Album")
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
}
Row {
width: parent.width
height: 154
spacing: 16
Rectangle {
width: 154
height: 154
radius: theme.radiusMd
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
clip: true
Image {
id: albumArtwork
anchors.fill: parent
source: root.artwork.previewPath || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
visible: source !== ""
}
QbzIcon {
anchors.centerIn: parent
name: "disc-3"
width: 42
height: 42
tintName: "muted"
visible: !albumArtwork.visible
}
QbzSpinner {
anchors.centerIn: parent
size: 26
visible: QbzTagEditor.artworkLoading
}
}
Column {
width: parent.width - 170
spacing: 8
Row {
id: artworkActions
width: parent.width
spacing: 8
SettingsButton {
text: root.artwork.previewPath ? root.tr("Choose another file") : root.tr("Choose file")
iconName: "image-plus"
minWidth: 0
enabled: !QbzTagEditor.artworkLoading
onClicked: QbzTagEditor.chooseArtwork()
}
SettingsButton {
visible: (root.artwork.token || "") !== ""
text: root.tr("Revert selection")
iconName: "rotate-ccw"
minWidth: 0
enabled: !QbzTagEditor.artworkLoading
onClicked: QbzTagEditor.clearArtwork()
}
Text {
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, artworkActions.width
- artworkActions.children[0].width
- artworkActions.children[1].width
- artworkActions.spacing * 2)
text: (root.artwork.source || "")
+ (root.artwork.width ? " · " + root.artwork.width + " × " + root.artwork.height : "")
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
Row {
spacing: 8
QbzSelect {
width: 150
menuWidth: 150
options: ["MusicBrainz", "Discogs", "Last.fm"]
currentIndex: root.artworkProvider === "discogs" ? 1
: root.artworkProvider === "lastfm" ? 2 : 0
enabled: !QbzTagEditor.artworkSearching && !QbzTagEditor.artworkLoading
onSelected: function(index) {
root.artworkProvider = index === 1 ? "discogs"
: index === 2 ? "lastfm" : "musicbrainz"
}
}
SettingsButton {
text: QbzTagEditor.artworkSearching ? root.tr("Searching…") : root.tr("Find artwork")
iconName: "search"
minWidth: 0
enabled: !QbzTagEditor.artworkSearching && !QbzTagEditor.artworkLoading
onClicked: QbzTagEditor.searchArtwork(
root.artworkProvider,
root.albumTitle,
root.albumArtist,
root.catalogNumber)
}
}
ListView {
id: artworkList
width: parent.width
height: 92
orientation: ListView.Horizontal
spacing: 8
clip: true
model: root.artworkResults
delegate: Rectangle {
required property var modelData
width: 196
height: 88
radius: theme.radiusSm
color: artMouse.containsMouse ? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Image {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: 4
width: 80
source: modelData.previewUrl || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
Column {
anchors.left: parent.left
anchors.leftMargin: 92
anchors.right: parent.right
anchors.rightMargin: 7
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: modelData.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
Text {
width: parent.width
text: modelData.source || ""
color: theme.textSecondary
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
Text {
width: parent.width
text: modelData.detail || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
MouseArea {
id: artMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !QbzTagEditor.artworkLoading
onClicked: QbzTagEditor.selectArtwork(parent.modelData.id || "")
}
}
}
}
}
Grid {
width: parent.width
columns: 2
columnSpacing: 16
rowSpacing: 12
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("Album title"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.albumTitle
onEdited: function(value) { root.albumTitle = value }
onCommitted: function(value) { root.albumTitle = value }
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("Album artists (ordered; separate with semicolons)"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.albumArtistsText
onEdited: function(value) { root.albumArtistsText = value }
onCommitted: function(value) { root.albumArtistsText = value }
}
}
Row {
width: (content.width - 16) / 2
spacing: 8
QbzToggle {
anchors.verticalCenter: parent.verticalCenter
checked: root.compilation
onToggled: function(value) { root.compilation = value }
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.tr("Compilation / Various Artists")
color: theme.textSecondary
font.pixelSize: theme.fontBody
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("Album artist"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.albumArtist
onEdited: function(value) { root.albumArtist = value }
onCommitted: function(value) { root.albumArtist = value }
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("Genre"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.genre
onEdited: function(value) { root.genre = value }
onCommitted: function(value) { root.genre = value }
}
}
Row {
width: (content.width - 16) / 2
spacing: 12
Column {
width: (parent.width - 12) * 0.34
spacing: 5
Text { text: root.tr("Year"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.year
onEdited: function(value) { root.year = value }
onCommitted: function(value) { root.year = value }
}
}
Column {
width: (parent.width - 12) * 0.66
spacing: 5
Text { text: root.tr("Catalog number"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.catalogNumber
onEdited: function(value) { root.catalogNumber = value }
onCommitted: function(value) { root.catalogNumber = value }
}
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("MusicBrainz release ID"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.musicbrainzReleaseId
onEdited: function(value) { root.musicbrainzReleaseId = value }
onCommitted: function(value) { root.musicbrainzReleaseId = value }
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("MusicBrainz release-group ID"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.musicbrainzReleaseGroupId
onEdited: function(value) { root.musicbrainzReleaseGroupId = value }
onCommitted: function(value) { root.musicbrainzReleaseGroupId = value }
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("MusicBrainz album artist IDs"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.musicbrainzAlbumArtistIdsText
onEdited: function(value) { root.musicbrainzAlbumArtistIdsText = value }
onCommitted: function(value) { root.musicbrainzAlbumArtistIdsText = value }
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("Discogs release ID"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.discogsReleaseId
onEdited: function(value) { root.discogsReleaseId = value }
onCommitted: function(value) { root.discogsReleaseId = value }
}
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
Row {
width: parent.width
spacing: 16
Column {
width: (parent.width - 16) * 0.53
spacing: 10
Text {
text: root.tr("Tag layers")
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
}
Text {
width: parent.width
text: root.inspection.error
? root.inspection.error
: root.tr("Canonical") + ": "
+ root.layerSummary(root.inspection.canonicalLayers || [])
color: root.inspection.error ? theme.danger : theme.textSecondary
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
}
Text {
width: parent.width
text: root.tr("Detected") + ": "
+ root.layerSummary(root.inspection.presentLayers || [])
color: theme.textMuted
font.pixelSize: theme.fontLegal
wrapMode: Text.WordWrap
}
Rectangle {
visible: (root.inspection.conflictingFiles || 0) > 0
width: parent.width
height: conflictText.implicitHeight + 18
radius: theme.radiusSm
color: theme.warningBg
Text {
id: conflictText
anchors.fill: parent
anchors.margins: 9
text: root.tr("Some files contain conflicting tag layers. The canonical layer wins unless synchronization is enabled.")
color: theme.textPrimary
font.pixelSize: theme.fontLegal
wrapMode: Text.WordWrap
}
}
}
Column {
width: (parent.width - 16) * 0.47
spacing: 10
Text {
text: root.tr("Find metadata")
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
}
Row {
width: parent.width
spacing: 8
QbzSelect {
width: 150
menuWidth: 150
options: ["MusicBrainz", "Discogs"]
currentIndex: root.remoteProvider === "discogs" ? 1 : 0
enabled: !QbzTagEditor.remoteSearching && !QbzTagEditor.remoteLoading
onSelected: function(index) {
root.remoteProvider = index === 1 ? "discogs" : "musicbrainz"
}
}
SettingsButton {
text: QbzTagEditor.remoteSearching ? root.tr("Searching…") : root.tr("Search")
iconName: "search"
minWidth: 0
enabled: !QbzTagEditor.remoteSearching && !QbzTagEditor.remoteLoading
onClicked: QbzTagEditor.searchRemote(root.remoteProvider, root.albumTitle, root.albumArtist)
}
}
ListView {
id: resultList
width: parent.width
height: 116
clip: true
spacing: 4
model: root.remoteResults
delegate: Rectangle {
required property var modelData
width: resultList.width
height: 54
radius: theme.radiusSm
color: resultArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
Column {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.right: openResult.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: modelData.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontBody
elide: Text.ElideRight
}
Text {
width: parent.width
text: (modelData.artist || "")
+ (modelData.year ? " · " + modelData.year : "")
+ (modelData.track_count ? " · " + modelData.track_count + " " + root.tr("tracks") : "")
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
QbzIcon {
id: openResult
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
name: QbzTagEditor.remoteLoading ? "loader-circle" : "chevron-right"
width: 15
height: 15
tintName: "muted"
}
MouseArea {
id: resultArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !QbzTagEditor.remoteLoading
onClicked: QbzTagEditor.loadRemote(root.remoteProvider, parent.modelData.provider_id)
}
}
}
Row {
visible: root.remoteMetadata !== null
spacing: 8
SettingsButton {
text: root.tr("Open source")
iconName: "external-link"
minWidth: 0
onClicked: QbzTagEditor.openRemote(String(root.remoteMetadata.provider || root.remoteProvider), root.remoteMetadata.provider_id || "")
}
SettingsButton {
text: root.tr("Apply to form")
iconName: "download"
minWidth: 0
onClicked: root.applyRemote()
}
}
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
Row {
width: parent.width
Text {
text: root.tr("Tracks")
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
}
Text {
width: parent.width - parent.children[0].implicitWidth
horizontalAlignment: Text.AlignRight
text: root.tr("File names are shown for reference and cannot be changed here.")
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
Rectangle {
width: parent.width
height: 32
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Row {
anchors.fill: parent
Text {
width: 64
height: parent.height
leftPadding: 10
text: root.tr("Disc")
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Text {
width: 72
height: parent.height
leftPadding: 10
text: root.tr("Track")
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Text {
width: Math.max(160, parent.width - 64 - 72 - 220 - 240 - 4)
height: parent.height
leftPadding: 10
text: root.tr("Title")
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Text {
width: 220
height: parent.height
leftPadding: 10
text: root.tr("Artist credit")
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Text {
width: 240
height: parent.height
leftPadding: 10
text: root.tr("File")
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
}
}
ListView {
id: trackList
width: parent.width
height: Math.min(360, Math.max(112, root.tracks.length * 36))
clip: true
spacing: 0
model: root.tracks
delegate: Rectangle {
id: trackDelegate
required property var modelData
required property int index
width: trackList.width
height: 36
color: trackDelegate.index % 2
? theme.surfaceElevated : "transparent"
border.width: 1
border.color: theme.borderSubtle
Row {
anchors.fill: parent
Rectangle {
width: 64
height: parent.height
color: "transparent"
TextInput {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 8
text: modelData.discNumber
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
inputMethodHints: Qt.ImhDigitsOnly
onTextEdited: modelData.discNumber = text
onActiveFocusChanged: if (activeFocus)
root.selectedTrackIndex = trackDelegate.index
}
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Rectangle {
width: 72
height: parent.height
color: "transparent"
TextInput {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 8
text: modelData.trackNumber
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
inputMethodHints: Qt.ImhDigitsOnly
onTextEdited: modelData.trackNumber = text
onActiveFocusChanged: if (activeFocus)
root.selectedTrackIndex = trackDelegate.index
}
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Rectangle {
width: Math.max(160, parent.width - 64 - 72 - 220 - 240 - 4)
height: parent.height
color: "transparent"
TextInput {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 8
text: modelData.title
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
clip: true
onTextEdited: modelData.title = text
onActiveFocusChanged: if (activeFocus)
root.selectedTrackIndex = trackDelegate.index
}
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Rectangle {
width: 220
height: parent.height
color: "transparent"
TextInput {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 8
text: modelData.artistCredit
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
clip: true
onTextEdited: modelData.artistCredit = text
onActiveFocusChanged: if (activeFocus)
root.selectedTrackIndex = trackDelegate.index
}
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Text {
id: fileName
width: 240
height: parent.height
leftPadding: 10
rightPadding: 8
text: modelData.fileName
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
elide: Text.ElideMiddle
MouseArea {
anchors.fill: parent
onClicked: root.selectedTrackIndex = trackDelegate.index
}
}
}
}
}
Rectangle {
id: trackDetails
property var rowData: root.selectedTrackIndex >= 0
&& root.selectedTrackIndex < root.tracks.length
? root.tracks[root.selectedTrackIndex] : null
visible: rowData !== null
width: parent.width
height: visible ? 190 : 0
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
radius: theme.radiusSm
Column {
anchors.fill: parent
anchors.margins: 10
spacing: 8
Row {
width: parent.width
Text {
text: root.tr("Selected track details")
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
}
Text {
width: parent.width - parent.children[0].implicitWidth
horizontalAlignment: Text.AlignRight
text: trackDetails.rowData ? trackDetails.rowData.fileName : ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideMiddle
}
}
Grid {
width: parent.width
columns: 2
columnSpacing: 12
rowSpacing: 7
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("Artists (ordered; semicolon separated)"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? root.listText(trackDetails.rowData.artists) : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.artists = root.splitList(value) }
}
}
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("Composers"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? root.listText(trackDetails.rowData.composers) : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.composers = root.splitList(value) }
}
}
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("Performers"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? root.listText(trackDetails.rowData.performers) : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.performers = root.splitList(value) }
}
}
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("MusicBrainz artist IDs"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? root.listText(trackDetails.rowData.musicbrainzArtistIds) : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.musicbrainzArtistIds = root.splitList(value) }
}
}
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("MusicBrainz recording ID"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? trackDetails.rowData.musicbrainzRecordingId : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.musicbrainzRecordingId = value }
}
}
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("MusicBrainz track ID"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? trackDetails.rowData.musicbrainzTrackId : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.musicbrainzTrackId = value }
}
}
}
}
}
}
QbzScrollBar {
target: scroll
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
anchors.margins: 16
visible: root.seeded && !QbzTagEditor.editorLoading
editor: root
}
}
Rectangle { y: body.y + body.height; width: parent.width; height: 1; color: theme.borderSubtle }
File diff suppressed because it is too large. Load diff