From 5e759fab502e2b6f47eba2ef44a57afeb0935ea2 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:37:11 -0600 Subject: [PATCH] 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 --- crates/qbz-qt/build.rs | 1 + crates/qbz-qt/qml/controls/TagEditorModal.qml | 788 +------------ .../qml/controls/TagEditorWorkspace.qml | 1036 +++++++++++++++++ crates/qbz-qt/src/tag_editor_qt.rs | 17 +- 4 files changed, 1055 insertions(+), 787 deletions(-) create mode 100644 crates/qbz-qt/qml/controls/TagEditorWorkspace.qml diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 0a2b37e7c..54e873f0c 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -478,6 +478,7 @@ fn main() { "qml/controls/TrackReplacementModal.qml", "qml/controls/DiscMetaModal.qml", "qml/controls/TagEditorModal.qml", + "qml/controls/TagEditorWorkspace.qml", "qml/controls/RipWizardModal.qml", "qml/controls/RipProgressModal.qml", "qml/controls/RipTick.qml", diff --git a/crates/qbz-qt/qml/controls/TagEditorModal.qml b/crates/qbz-qt/qml/controls/TagEditorModal.qml index be2ed9f90..e24dfc285 100644 --- a/crates/qbz-qt/qml/controls/TagEditorModal.qml +++ b/crates/qbz-qt/qml/controls/TagEditorModal.qml @@ -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 } diff --git a/crates/qbz-qt/qml/controls/TagEditorWorkspace.qml b/crates/qbz-qt/qml/controls/TagEditorWorkspace.qml new file mode 100644 index 000000000..bc743306b --- /dev/null +++ b/crates/qbz-qt/qml/controls/TagEditorWorkspace.qml @@ -0,0 +1,1036 @@ +// Responsive workspace for the local metadata editor. Album fields and the +// track spreadsheet scroll independently so the primary editing surface does +// not fall below a long form. Wide windows use an 8/12 + 4/12 split; compact +// windows switch between the two panes instead of stacking them. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: workspace + required property var editor + + property string compactPane: "tracks" + property bool advancedOpen: false + property bool lookupOpen: false + readonly property bool wide: width >= 1120 + readonly property int gap: 12 + + QbzTheme { id: theme } + + Row { + id: compactSwitch + visible: !workspace.wide + height: visible ? 34 : 0 + spacing: 6 + anchors.horizontalCenter: parent.horizontalCenter + + Repeater { + model: [ + { "id": "tracks", "label": workspace.editor.tr("Tracks") }, + { "id": "tags", "label": workspace.editor.tr("Album tags") } + ] + delegate: Rectangle { + id: paneChoice + required property var modelData + width: 128 + height: 32 + radius: theme.radiusSm + color: workspace.compactPane === modelData.id + ? theme.surfaceElevated + : choiceMouse.containsMouse ? theme.surfaceHover : "transparent" + border.width: workspace.compactPane === modelData.id ? 1 : 0 + border.color: theme.borderMuted + Text { + anchors.centerIn: parent + text: paneChoice.modelData.label + color: workspace.compactPane === paneChoice.modelData.id + ? theme.textPrimary : theme.textSecondary + font.pixelSize: theme.fontBody + font.weight: workspace.compactPane === paneChoice.modelData.id + ? theme.weightSemibold : theme.weightRegular + } + MouseArea { + id: choiceMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: workspace.compactPane = paneChoice.modelData.id + } + } + } + } + + Item { + id: panes + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.top: workspace.wide ? parent.top : compactSwitch.bottom + anchors.topMargin: workspace.wide ? 0 : 8 + + Rectangle { + id: trackPane + visible: workspace.wide || workspace.compactPane === "tracks" + x: 0 + width: workspace.wide + ? Math.floor((panes.width - workspace.gap) * 8 / 12) + : panes.width + height: panes.height + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + clip: true + + property bool detailsOpen: false + readonly property int tableW: Math.max(0, width - 16) + readonly property int discW: 56 + readonly property int trackW: 64 + readonly property int artistW: Math.max(150, tableW * 0.23) + readonly property int fileW: Math.max(150, tableW * 0.22) + readonly property int titleW: Math.max(180, + tableW - discW - trackW - artistW - fileW) + + Item { + id: trackTitle + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: 44 + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: workspace.editor.tr("Tracks") + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + } + Text { + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - 120) + horizontalAlignment: Text.AlignRight + text: workspace.editor.tr("File names are shown for reference and cannot be changed here.") + color: theme.textMuted + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + } + + Rectangle { + id: tableHeader + anchors.left: parent.left + anchors.right: parent.right + anchors.top: trackTitle.bottom + anchors.leftMargin: 8 + anchors.rightMargin: 8 + height: 32 + color: theme.alphaTier(8) + border.width: 1 + border.color: theme.borderMuted + + Row { + anchors.fill: parent + Repeater { + model: [ + { "label": workspace.editor.tr("Disc"), "width": trackPane.discW }, + { "label": workspace.editor.tr("Track"), "width": trackPane.trackW }, + { "label": workspace.editor.tr("Title"), "width": trackPane.titleW }, + { "label": workspace.editor.tr("Artist credit"), "width": trackPane.artistW }, + { "label": workspace.editor.tr("File"), "width": trackPane.fileW } + ] + delegate: Item { + required property var modelData + width: modelData.width + height: tableHeader.height + Text { + anchors.fill: parent + leftPadding: 9 + rightPadding: 7 + text: parent.modelData.label + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Rectangle { + anchors.right: parent.right + width: 1 + height: parent.height + color: theme.borderSubtle + } + } + } + } + } + + ListView { + id: trackList + anchors.left: parent.left + anchors.right: parent.right + anchors.top: tableHeader.bottom + anchors.bottom: detailsBar.top + anchors.leftMargin: 8 + anchors.rightMargin: 8 + clip: true + boundsBehavior: Flickable.StopAtBounds + model: workspace.editor.tracks + + delegate: Rectangle { + id: trackDelegate + required property var modelData + required property int index + width: trackList.width + height: 32 + readonly property bool selected: + workspace.editor.selectedTrackIndex === trackDelegate.index + color: selected ? theme.alphaTier(12) + : rowHover.hovered ? theme.surfaceHover + : trackDelegate.index % 2 ? theme.alphaTier(4) : "transparent" + + HoverHandler { id: rowHover } + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: workspace.editor.selectedTrackIndex = trackDelegate.index + } + Rectangle { + anchors.left: parent.left + width: trackDelegate.selected ? 2 : 0 + height: parent.height + color: theme.accent + } + Row { + anchors.fill: parent + + component EditCell: Rectangle { + id: cell + property alias text: input.text + property bool numeric: false + signal edited(string value) + signal focused() + height: trackDelegate.height + color: input.activeFocus ? theme.alphaTier(8) : "transparent" + border.width: input.activeFocus ? 1 : 0 + border.color: theme.focusRing + TextInput { + id: input + anchors.fill: parent + leftPadding: 9 + rightPadding: 7 + color: theme.textPrimary + selectionColor: theme.accent + selectedTextColor: theme.accentText + font.pixelSize: theme.fontBody + verticalAlignment: TextInput.AlignVCenter + selectByMouse: true + clip: true + inputMethodHints: cell.numeric ? Qt.ImhDigitsOnly : Qt.ImhNone + onTextEdited: cell.edited(text) + onActiveFocusChanged: if (activeFocus) cell.focused() + } + Rectangle { + anchors.right: parent.right + width: 1 + height: parent.height + color: theme.borderSubtle + } + } + + EditCell { + width: trackPane.discW + numeric: true + text: trackDelegate.modelData.discNumber + onEdited: function(value) { trackDelegate.modelData.discNumber = value } + onFocused: workspace.editor.selectedTrackIndex = trackDelegate.index + } + EditCell { + width: trackPane.trackW + numeric: true + text: trackDelegate.modelData.trackNumber + onEdited: function(value) { trackDelegate.modelData.trackNumber = value } + onFocused: workspace.editor.selectedTrackIndex = trackDelegate.index + } + EditCell { + width: trackPane.titleW + text: trackDelegate.modelData.title + onEdited: function(value) { trackDelegate.modelData.title = value } + onFocused: workspace.editor.selectedTrackIndex = trackDelegate.index + } + EditCell { + width: trackPane.artistW + text: trackDelegate.modelData.artistCredit + onEdited: function(value) { trackDelegate.modelData.artistCredit = value } + onFocused: workspace.editor.selectedTrackIndex = trackDelegate.index + } + Item { + width: trackPane.fileW + height: trackDelegate.height + Text { + anchors.fill: parent + leftPadding: 9 + rightPadding: 7 + text: trackDelegate.modelData.fileName + color: theme.textMuted + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + elide: Text.ElideMiddle + } + } + } + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: theme.borderSubtle + } + } + QbzScrollBar { + target: trackList + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + } + } + + Rectangle { + id: detailsBar + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: detailsPane.top + height: 38 + color: detailsMouse.containsMouse ? theme.surfaceHover : theme.alphaTier(6) + border.width: 1 + border.color: theme.borderSubtle + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 48 + text: workspace.editor.selectedTrackIndex >= 0 + && workspace.editor.selectedTrackIndex < workspace.editor.tracks.length + ? workspace.editor.tr("Selected track details") + " · " + + workspace.editor.tracks[workspace.editor.selectedTrackIndex].fileName + : workspace.editor.tr("Select a track to edit its extended credits") + color: theme.textSecondary + font.pixelSize: theme.fontBody + elide: Text.ElideMiddle + } + QbzIcon { + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + width: 14 + height: 14 + name: trackPane.detailsOpen ? "chevron-down" : "chevron-up" + tintName: "muted" + } + MouseArea { + id: detailsMouse + anchors.fill: parent + hoverEnabled: true + enabled: workspace.editor.selectedTrackIndex >= 0 + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: trackPane.detailsOpen = !trackPane.detailsOpen + } + } + + Rectangle { + id: detailsPane + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: trackPane.detailsOpen ? 218 : 0 + visible: height > 0 + color: theme.alphaTier(4) + clip: true + property var rowData: workspace.editor.selectedTrackIndex >= 0 + && workspace.editor.selectedTrackIndex < workspace.editor.tracks.length + ? workspace.editor.tracks[workspace.editor.selectedTrackIndex] : null + + Grid { + anchors.fill: parent + anchors.margins: 10 + columns: 2 + columnSpacing: 10 + rowSpacing: 6 + + component DetailField: Column { + property string label: "" + property string value: "" + signal edited(string value) + width: (detailsPane.width - 30) / 2 + spacing: 3 + Text { + width: parent.width + text: parent.label + color: theme.textMuted + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + QbzLineEdit { + width: parent.width + text: parent.value + onEdited: function(value) { parent.edited(value) } + } + } + + DetailField { + label: workspace.editor.tr("Artists (ordered; semicolon separated)") + value: detailsPane.rowData ? workspace.editor.listText(detailsPane.rowData.artists) : "" + onEdited: function(value) { if (detailsPane.rowData) detailsPane.rowData.artists = workspace.editor.splitList(value) } + } + DetailField { + label: workspace.editor.tr("Composers") + value: detailsPane.rowData ? workspace.editor.listText(detailsPane.rowData.composers) : "" + onEdited: function(value) { if (detailsPane.rowData) detailsPane.rowData.composers = workspace.editor.splitList(value) } + } + DetailField { + label: workspace.editor.tr("Performers") + value: detailsPane.rowData ? workspace.editor.listText(detailsPane.rowData.performers) : "" + onEdited: function(value) { if (detailsPane.rowData) detailsPane.rowData.performers = workspace.editor.splitList(value) } + } + DetailField { + label: workspace.editor.tr("MusicBrainz artist IDs") + value: detailsPane.rowData ? workspace.editor.listText(detailsPane.rowData.musicbrainzArtistIds) : "" + onEdited: function(value) { if (detailsPane.rowData) detailsPane.rowData.musicbrainzArtistIds = workspace.editor.splitList(value) } + } + DetailField { + label: workspace.editor.tr("MusicBrainz recording ID") + value: detailsPane.rowData ? detailsPane.rowData.musicbrainzRecordingId : "" + onEdited: function(value) { if (detailsPane.rowData) detailsPane.rowData.musicbrainzRecordingId = value } + } + DetailField { + label: workspace.editor.tr("MusicBrainz track ID") + value: detailsPane.rowData ? detailsPane.rowData.musicbrainzTrackId : "" + onEdited: function(value) { if (detailsPane.rowData) detailsPane.rowData.musicbrainzTrackId = value } + } + } + } + } + + Rectangle { + id: tagPane + visible: workspace.wide || workspace.compactPane === "tags" + x: workspace.wide ? trackPane.width + workspace.gap : 0 + width: workspace.wide ? panes.width - x : panes.width + height: panes.height + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + clip: true + + Flickable { + id: tagScroll + anchors.fill: parent + anchors.margins: 12 + contentWidth: width + contentHeight: tagContent.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + id: tagContent + width: tagScroll.width - 12 + spacing: 10 + + Row { + width: parent.width + height: 96 + spacing: 10 + Rectangle { + width: 96 + height: 96 + radius: theme.radiusSm + color: theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + clip: true + Image { + id: cover + anchors.fill: parent + source: workspace.editor.artwork.previewPath || "" + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: false + visible: source !== "" + } + QbzIcon { + anchors.centerIn: parent + width: 32 + height: 32 + name: "disc-3" + tintName: "muted" + visible: !cover.visible + } + Rectangle { + anchors.fill: parent + color: theme.surfaceMainA50 + visible: QbzTagEditor.artworkSearching || QbzTagEditor.artworkLoading + QbzSpinner { + anchors.centerIn: parent + size: 24 + } + } + } + Column { + width: parent.width - 106 + spacing: 7 + Text { + width: parent.width + text: workspace.editor.tr("Album artwork") + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Row { + spacing: 6 + SettingsButton { + text: workspace.editor.artwork.previewPath + ? workspace.editor.tr("Replace file") + : workspace.editor.tr("Choose file") + iconName: "image-plus" + minWidth: 0 + enabled: !QbzTagEditor.artworkLoading + onClicked: QbzTagEditor.chooseArtwork() + } + QbzIconButton { + visible: (workspace.editor.artwork.token || "") !== "" + name: "rotate-ccw" + tooltipText: workspace.editor.tr("Revert selection") + btnEnabled: !QbzTagEditor.artworkLoading + onClicked: QbzTagEditor.clearArtwork() + } + } + SettingsButton { + text: workspace.editor.tr("Find metadata and artwork") + iconName: "search" + minWidth: 0 + onClicked: workspace.lookupOpen = true + } + } + } + + component Field: Column { + property string label: "" + property string value: "" + signal edited(string value) + width: tagContent.width + spacing: 3 + Text { + width: parent.width + text: parent.label + color: theme.textSecondary + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + QbzLineEdit { + width: parent.width + text: parent.value + onEdited: function(value) { parent.edited(value) } + onCommitted: function(value) { parent.edited(value) } + } + } + + Field { + label: workspace.editor.tr("Album title") + value: workspace.editor.albumTitle + onEdited: function(value) { workspace.editor.albumTitle = value } + } + Field { + label: workspace.editor.tr("Album artists (ordered; separate with semicolons)") + value: workspace.editor.albumArtistsText + onEdited: function(value) { workspace.editor.albumArtistsText = value } + } + Field { + label: workspace.editor.tr("Album artist") + value: workspace.editor.albumArtist + onEdited: function(value) { workspace.editor.albumArtist = value } + } + Row { + width: parent.width + spacing: 7 + QbzToggle { + anchors.verticalCenter: parent.verticalCenter + checked: workspace.editor.compilation + onToggled: function(value) { workspace.editor.compilation = value } + } + Text { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 40 + text: workspace.editor.tr("Compilation / Various Artists") + color: theme.textSecondary + font.pixelSize: theme.fontBody + elide: Text.ElideRight + } + } + Field { + label: workspace.editor.tr("Genre") + value: workspace.editor.genre + onEdited: function(value) { workspace.editor.genre = value } + } + Row { + width: parent.width + spacing: 8 + Field { + width: (parent.width - 8) * 0.34 + label: workspace.editor.tr("Year") + value: workspace.editor.year + onEdited: function(value) { workspace.editor.year = value } + } + Field { + width: (parent.width - 8) * 0.66 + label: workspace.editor.tr("Catalog number") + value: workspace.editor.catalogNumber + onEdited: function(value) { workspace.editor.catalogNumber = value } + } + } + + Rectangle { + width: parent.width + height: 34 + radius: theme.radiusSm + color: advancedMouse.containsMouse ? theme.surfaceHover : theme.alphaTier(4) + Text { + anchors.left: parent.left + anchors.leftMargin: 9 + anchors.verticalCenter: parent.verticalCenter + text: workspace.editor.tr("Provider identifiers") + color: theme.textSecondary + font.pixelSize: theme.fontBody + } + QbzIcon { + anchors.right: parent.right + anchors.rightMargin: 9 + anchors.verticalCenter: parent.verticalCenter + width: 13 + height: 13 + name: workspace.advancedOpen ? "chevron-up" : "chevron-down" + tintName: "muted" + } + MouseArea { + id: advancedMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: workspace.advancedOpen = !workspace.advancedOpen + } + } + Column { + visible: workspace.advancedOpen + width: parent.width + height: visible ? implicitHeight : 0 + spacing: 8 + Field { + label: workspace.editor.tr("MusicBrainz release ID") + value: workspace.editor.musicbrainzReleaseId + onEdited: function(value) { workspace.editor.musicbrainzReleaseId = value } + } + Field { + label: workspace.editor.tr("MusicBrainz release-group ID") + value: workspace.editor.musicbrainzReleaseGroupId + onEdited: function(value) { workspace.editor.musicbrainzReleaseGroupId = value } + } + Field { + label: workspace.editor.tr("MusicBrainz album artist IDs") + value: workspace.editor.musicbrainzAlbumArtistIdsText + onEdited: function(value) { workspace.editor.musicbrainzAlbumArtistIdsText = value } + } + Field { + label: workspace.editor.tr("Discogs release ID") + value: workspace.editor.discogsReleaseId + onEdited: function(value) { workspace.editor.discogsReleaseId = value } + } + } + + Rectangle { width: parent.width; height: 1; color: theme.borderSubtle } + Text { + text: workspace.editor.tr("Tag layers") + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + } + Text { + width: parent.width + text: workspace.editor.inspection.error + ? workspace.editor.inspection.error + : workspace.editor.tr("Canonical") + ": " + + workspace.editor.layerSummary(workspace.editor.inspection.canonicalLayers || []) + color: workspace.editor.inspection.error ? theme.danger : theme.textSecondary + font.pixelSize: theme.fontLegal + wrapMode: Text.WordWrap + } + Text { + width: parent.width + text: workspace.editor.tr("Detected") + ": " + + workspace.editor.layerSummary(workspace.editor.inspection.presentLayers || []) + color: theme.textMuted + font.pixelSize: theme.fontLegal + wrapMode: Text.WordWrap + } + Rectangle { + visible: (workspace.editor.inspection.conflictingFiles || 0) > 0 + width: parent.width + height: visible ? conflictText.implicitHeight + 16 : 0 + radius: theme.radiusSm + color: theme.warningBg + Text { + id: conflictText + anchors.fill: parent + anchors.margins: 8 + text: workspace.editor.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 + } + } + } + QbzScrollBar { + target: tagScroll + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + } + } + } + } + + Rectangle { + id: lookupShield + anchors.fill: parent + visible: workspace.lookupOpen + z: 100 + color: theme.cardShadow + MouseArea { + anchors.fill: parent + onClicked: workspace.lookupOpen = false + } + + Rectangle { + id: lookupCard + anchors.centerIn: parent + width: Math.min(960, parent.width - 40) + height: Math.min(640, parent.height - 32) + radius: theme.radiusLg + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderStrong + clip: true + MouseArea { anchors.fill: parent } + + Item { + id: lookupHeader + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: 52 + Text { + anchors.left: parent.left + anchors.leftMargin: 16 + anchors.verticalCenter: parent.verticalCenter + text: workspace.editor.tr("Find metadata and artwork") + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + } + QbzIconButton { + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + name: "x" + tooltipText: workspace.editor.tr("Close") + onClicked: workspace.lookupOpen = false + } + } + Rectangle { + anchors.top: lookupHeader.bottom + width: parent.width + height: 1 + color: theme.borderSubtle + } + + Row { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: lookupHeader.bottom + anchors.bottom: parent.bottom + anchors.margins: 14 + anchors.topMargin: 15 + spacing: 14 + + Column { + id: metadataLookup + width: (parent.width - 14) / 2 + height: parent.height + spacing: 9 + Text { + text: workspace.editor.tr("Release metadata") + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + } + Row { + width: parent.width + spacing: 7 + QbzSelect { + width: 150 + menuWidth: 150 + options: ["MusicBrainz", "Discogs"] + currentIndex: workspace.editor.remoteProvider === "discogs" ? 1 : 0 + enabled: !QbzTagEditor.remoteSearching && !QbzTagEditor.remoteLoading + onSelected: function(index) { + workspace.editor.remoteProvider = index === 1 ? "discogs" : "musicbrainz" + } + } + SettingsButton { + text: workspace.editor.tr("Search") + iconName: "search" + minWidth: 0 + enabled: !QbzTagEditor.remoteSearching && !QbzTagEditor.remoteLoading + onClicked: QbzTagEditor.searchRemote( + workspace.editor.remoteProvider, + workspace.editor.albumTitle, + workspace.editor.albumArtist) + } + QbzSpinner { + anchors.verticalCenter: parent.verticalCenter + size: 20 + visible: QbzTagEditor.remoteSearching || QbzTagEditor.remoteLoading + } + } + Rectangle { + id: metadataHeader + width: parent.width + height: 28 + color: theme.alphaTier(8) + border.width: 1 + border.color: theme.borderMuted + Row { + anchors.fill: parent + component HeaderCell: Text { + height: metadataHeader.height + leftPadding: 7 + rightPadding: 5 + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + HeaderCell { width: parent.width * 0.43; text: workspace.editor.tr("Title") } + HeaderCell { width: parent.width * 0.34; text: workspace.editor.tr("Artist") } + HeaderCell { width: parent.width * 0.12; text: workspace.editor.tr("Year") } + HeaderCell { width: parent.width * 0.11; text: workspace.editor.tr("Tracks") } + } + } + ListView { + id: metadataResults + width: parent.width + height: parent.height - 160 + clip: true + spacing: 0 + model: workspace.editor.remoteResults + delegate: Rectangle { + id: metadataResult + required property var modelData + required property int index + width: metadataResults.width + height: 32 + radius: 0 + color: metadataMouse.containsMouse ? theme.surfaceHover + : metadataResult.index % 2 ? theme.alphaTier(4) : "transparent" + Row { + anchors.fill: parent + Text { + width: parent.width * 0.43 + height: parent.height + leftPadding: 7 + rightPadding: 5 + text: metadataResult.modelData.title || "" + color: theme.textPrimary + font.pixelSize: theme.fontBody + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Text { + width: parent.width * 0.34 + height: parent.height + leftPadding: 7 + rightPadding: 5 + text: metadataResult.modelData.artist || "" + color: theme.textSecondary + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Text { + width: parent.width * 0.12 + height: parent.height + leftPadding: 7 + text: metadataResult.modelData.year || "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + } + Text { + width: parent.width * 0.11 + height: parent.height + leftPadding: 7 + text: metadataResult.modelData.track_count || "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + } + } + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: theme.borderSubtle + } + MouseArea { + id: metadataMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !QbzTagEditor.remoteLoading + onClicked: QbzTagEditor.loadRemote( + workspace.editor.remoteProvider, + metadataResult.modelData.provider_id) + } + } + } + Row { + visible: workspace.editor.remoteMetadata !== null + spacing: 7 + SettingsButton { + text: workspace.editor.tr("Apply to form") + iconName: "download" + primary: true + minWidth: 0 + onClicked: workspace.editor.applyRemote() + } + SettingsButton { + text: workspace.editor.tr("Open source") + iconName: "external-link" + minWidth: 0 + onClicked: QbzTagEditor.openRemote( + String(workspace.editor.remoteMetadata.provider + || workspace.editor.remoteProvider), + workspace.editor.remoteMetadata.provider_id || "") + } + } + } + + Rectangle { width: 1; height: parent.height; color: theme.borderSubtle } + + Column { + id: artworkLookup + width: (parent.width - 15) / 2 + height: parent.height + spacing: 9 + Text { + text: workspace.editor.tr("Album artwork") + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + } + Row { + width: parent.width + spacing: 7 + QbzSelect { + width: 150 + menuWidth: 150 + options: ["MusicBrainz", "Discogs", "Last.fm"] + currentIndex: workspace.editor.artworkProvider === "discogs" ? 1 + : workspace.editor.artworkProvider === "lastfm" ? 2 : 0 + enabled: !QbzTagEditor.artworkSearching && !QbzTagEditor.artworkLoading + onSelected: function(index) { + workspace.editor.artworkProvider = index === 1 ? "discogs" + : index === 2 ? "lastfm" : "musicbrainz" + } + } + SettingsButton { + text: workspace.editor.tr("Search") + iconName: "search" + minWidth: 0 + enabled: !QbzTagEditor.artworkSearching && !QbzTagEditor.artworkLoading + onClicked: QbzTagEditor.searchArtwork( + workspace.editor.artworkProvider, + workspace.editor.albumTitle, + workspace.editor.albumArtist, + workspace.editor.catalogNumber) + } + QbzSpinner { + anchors.verticalCenter: parent.verticalCenter + size: 20 + visible: QbzTagEditor.artworkSearching || QbzTagEditor.artworkLoading + } + } + GridView { + id: artworkResults + width: parent.width + height: parent.height - 90 + clip: true + cellWidth: 108 + cellHeight: 126 + model: workspace.editor.artworkResults + delegate: Rectangle { + id: artworkResult + required property var modelData + width: 102 + height: 120 + radius: 0 + color: artworkMouse.containsMouse ? theme.surfaceHover : "transparent" + border.width: 1 + border.color: theme.borderSubtle + Image { + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: 3 + width: 94 + height: 94 + source: artworkResult.modelData.previewUrl || "" + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 4 + height: 18 + text: artworkResult.modelData.source || artworkResult.modelData.title || "" + color: theme.textSecondary + font.pixelSize: theme.fontLegal + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + MouseArea { + id: artworkMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !QbzTagEditor.artworkLoading + onClicked: QbzTagEditor.selectArtwork( + artworkResult.modelData.id || "") + } + } + QbzSpinner { + anchors.centerIn: parent + size: 26 + visible: QbzTagEditor.artworkSearching || QbzTagEditor.artworkLoading + } + } + } + } + } + } +} diff --git a/crates/qbz-qt/src/tag_editor_qt.rs b/crates/qbz-qt/src/tag_editor_qt.rs index 0929d4418..e9da8d886 100644 --- a/crates/qbz-qt/src/tag_editor_qt.rs +++ b/crates/qbz-qt/src/tag_editor_qt.rs @@ -1833,13 +1833,22 @@ mod tests { #[test] fn view_consumes_seed_when_loading_finishes_after_json_publish() { let qml = include_str!("../qml/controls/TagEditorModal.qml"); + let workspace = include_str!("../qml/controls/TagEditorWorkspace.qml"); assert!(qml.contains("function onEditorJsonChanged()")); assert!(qml.contains("function onEditorLoadingChanged()")); assert!(qml.contains("!QbzTagEditor.editorLoading")); assert!(qml.contains("event.kind === \"artwork-selected\"")); - assert!(qml.contains("QbzTagEditor.chooseArtwork()")); - assert!(qml.contains("id: trackDelegate")); - assert!(qml.contains("root.selectedTrackIndex = trackDelegate.index")); - assert!(!qml.contains("root.selectedTrackIndex = index")); + assert!(workspace.contains("QbzTagEditor.chooseArtwork()")); + assert!(workspace.contains("id: trackDelegate")); + assert!(workspace.contains("workspace.editor.selectedTrackIndex = trackDelegate.index")); + assert!(!workspace.contains("selectedTrackIndex = index")); + assert!(qml.contains("TagEditorWorkspace")); + assert!(workspace.contains("* 8 / 12")); + assert!(workspace.contains("workspace.compactPane === \"tracks\"")); + assert!(workspace.contains("workspace.compactPane === \"tags\"")); + assert!(workspace.contains("color: selected ? theme.alphaTier(12)")); + assert!(workspace + .contains("visible: QbzTagEditor.artworkSearching || QbzTagEditor.artworkLoading")); + assert!(workspace.contains("id: lookupCard")); } }