Add bounded native library models, complete grouping rails and scan progress. Bring source-aware metadata editing, media information and collection offline preflight to Qt, and repair inherited Plex album artwork without overriding healthy tokens. cx
295 lines
15 KiB
QML
295 lines
15 KiB
QML
// Local track row — the SHARED rows/TrackRow.qml with the local arms set,
|
|
// plus the three things the shared row has no arm for:
|
|
//
|
|
// * the always-visible SOURCE glyph the Slint renders from
|
|
// `show-source: true` (LocalLibraryView.slint:1489) — drawn OUTSIDE the
|
|
// shared row so TrackRow keeps one column layout for every surface;
|
|
// * the multi-select checkbox (Slint TrackRow's `multi-select-mode`
|
|
// swaps the number cell for it) — overlaid on the number cell here for
|
|
// the same reason: no fork of the shared row, no shared-file edit;
|
|
// * the LOCAL context menu.
|
|
//
|
|
// THE MENU (this is the `force-local-menu` arm of the unified Slint menu —
|
|
// TrackRow.slint:68 -> TrackMenuState -> AppShell.slint:1009-1021 ->
|
|
// TrackContextMenu.slint). For a local / Plex / offline row on a library
|
|
// surface Slint resolves to: qobuz-actions OFF, favorite OFF, Track info OFF
|
|
// (show-track-info rides the Qobuz block), mixtape ON, add-to-playlist ON,
|
|
// Go to album / Go to artist ON (`local-goto-actions`). So the full local set
|
|
// is: Play now, Play next, Play later, Add to queue, Add to mixtape, Add to
|
|
// playlist, Go to album, Go to artist.
|
|
//
|
|
// Add to playlist is LIVE (QbzPlaylistPicker). It goes through
|
|
// `QbzLocal.bulkAction("track", [id], "add-to-playlist")` — the LOCAL-mode
|
|
// arm — and not through the picker singleton directly, because only
|
|
// `local_bulk::resolve_blocking` can turn a Tracks-table row id into the
|
|
// `LocalTrack` that `local_picker_ref_for_track` needs; a Plex row's ref is
|
|
// "plex:<rating key>" and its numeric display id means a different track the
|
|
// moment anything reads it as a catalog id. The bulk entry point takes a JSON
|
|
// id array, so a single row is a one-element array — same code path, no
|
|
// second resolver.
|
|
//
|
|
// Add to mixtape rides the SAME entry point for the same reason
|
|
// (`local_bulk.rs::apply` "add-to-mixtape" -> `myqbz_add_qt::open_items(
|
|
// track_items_from_local(rows))`), which restores the .slint's order:
|
|
// Play now · Play next · Play later · Add to queue · Add to mixtape ·
|
|
// Add to playlist · Go to album · Go to artist. Every row that IS shown is
|
|
// wired; nothing here renders and no-ops.
|
|
//
|
|
// The shared row's own ⋯ is turned OFF (`showMenu: false`) because its
|
|
// entries are the Qobuz set: its Go-to-album/artist call QbzAlbum.openAlbum /
|
|
// QbzArtist.openArtist with CATALOG ids, and a local row carries a local
|
|
// group key + a bare artist NAME. The button is re-drawn here, in the exact
|
|
// same gutter (the shared row is narrowed by precisely the 32px cell + 14px
|
|
// gap it no longer draws), and both it and a right-click open this menu.
|
|
//
|
|
// Artwork follows the Slint gate (AppearanceState.local-library-track-
|
|
// artwork, default OFF) — decoding a cover per row is exactly what froze
|
|
// this surface at 16k tracks.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../../controls"
|
|
import "../../rows"
|
|
import "../../theme"
|
|
|
|
Item {
|
|
id: root
|
|
|
|
property var item: ({})
|
|
property int number: 0
|
|
property bool showAlbum: true
|
|
property bool showArtwork: false
|
|
property string artSource: ""
|
|
/// The LocalLibraryView root (shared skeleton pulse + artwork gate).
|
|
property var view: null
|
|
property bool selectMode: false
|
|
property bool checked: false
|
|
property bool nativeActions: false
|
|
property int nativeIndex: -1
|
|
/// Alternating row tint, forwarded to the shared row. Off by default there
|
|
/// (rows/TrackRow.qml), which is why the local album page had none while
|
|
/// the Qobuz one (AlbumView.qml) did — the flag was simply never handed
|
|
/// through this wrapper.
|
|
property bool zebra: false
|
|
signal playRequested()
|
|
signal enqueueRequested(string mode)
|
|
/// `modifiers` rides straight off the mouse event: Shift is what turns
|
|
/// a click into a range (controls/SelectionModel.qml).
|
|
signal toggleSelect(int modifiers)
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
height: 50
|
|
|
|
// The shared TrackRow takes its artwork from `item.artPath`, and a LOCAL
|
|
// track row publishes an empty one (local_rows.rs:284) — the cover comes
|
|
// from the id-keyed artMap instead. Patch it in rather than fork the
|
|
// shared row; the copy only happens when a cover is actually present.
|
|
readonly property var rowItem: root.artSource === ""
|
|
? root.item
|
|
: Object.assign({}, root.item, { "artPath": root.artSource })
|
|
readonly property string runtimeError: root.view
|
|
? (root.view.localTrackErrors[
|
|
root.view.localTrackErrorKey(root.item.source || "local", root.item.id)] || "")
|
|
: ""
|
|
|
|
// Local rows have a local album GROUP KEY and a bare artist NAME; both
|
|
// routes are name/key routes, so each entry is gated on its own datum.
|
|
readonly property bool canGoAlbum: (item.albumId || "") !== ""
|
|
readonly property bool canGoArtist: (item.artist || "") !== ""
|
|
// Local/network files may write a sidecar or their canonical tag layer;
|
|
// media-server rows use the same editor but are deliberately sidecar-only.
|
|
// Offline Qobuz copies are excluded: their cache file is not user-owned
|
|
// library metadata and has no stable server-album identity.
|
|
readonly property bool canEditMetadata: [
|
|
"local", "plex", "jellyfin", "subsonic", "navidrome",
|
|
"gonic", "airsonic", "astiga"
|
|
].indexOf(String(item.source || "").toLowerCase()) >= 0
|
|
|
|
// Zebra is painted HERE, not forwarded to the shared row. TrackRow tints
|
|
// its own background rectangle, which is `parent.width` wide — and this
|
|
// wrapper hands it `root.width - 26 - 46`, holding back a 26px gutter for
|
|
// the source glyph and 46px for the context menu it draws itself. Tinting
|
|
// from in there therefore stopped 72px short and left those two controls
|
|
// sitting outside the stripe. Declared before the shared row so it paints
|
|
// underneath, and with no pointer area of its own.
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: 8
|
|
// Same literal and same parity rule as rows/TrackRow.qml, so a local
|
|
// list and a Qobuz list stripe identically.
|
|
color: (root.zebra && root.number % 2 === 0) ? "#07ffffff" : "transparent"
|
|
}
|
|
|
|
TrackRow {
|
|
id: sharedRow
|
|
// 26px source-glyph gutter + the 46px (32 cell + 14 gap) the shared
|
|
// row would have spent on its own ⋯ — so every other column lands
|
|
// exactly where it did before the menu moved out.
|
|
width: root.width - 26 - 46
|
|
item: root.rowItem
|
|
number: root.number
|
|
showArtwork: root.showArtwork
|
|
showAlbum: root.showAlbum
|
|
showFavorite: false
|
|
showDownload: false
|
|
showMenu: false
|
|
// Local rows are NOT drag sources: item.id is a local DB row id and
|
|
// the sidebar drop handler forwards it as a Qobuz catalog id.
|
|
draggable: false
|
|
qualityStyle: "icon"
|
|
// A failed physical row stays clickable so the user can retry after a
|
|
// drive returns. The preflight prevents any queue/NPB mutation until
|
|
// that retry succeeds; the marker is cleared by the success signal.
|
|
leadingMarkerIcon: root.runtimeError !== "" ? "circle-alert" : ""
|
|
// Multi-select is the SHARED row's arm now (rows/TrackRow.qml:116,
|
|
// the port of TrackRow.slint:58 `multi-select-mode`). This file used
|
|
// to overlay its own SelectCheck on the number cell because the
|
|
// shared row had no such arm — it does now, and the overlay was
|
|
// strictly worse: it drew a checkbox but left `sharedRow.selectMode`
|
|
// false, so the row BODY kept its normal gestures and a single click
|
|
// anywhere off the 14px disc PLAYED the track instead of selecting
|
|
// it. TrackRow.slint:174 is explicit that in select mode "the WHOLE
|
|
// row body is a selection target", and LocalAlbumRow.qml:55 already
|
|
// does that for the albums tab. Forwarding the three members gets the
|
|
// reference behaviour for free: body-click toggles, double-click no
|
|
// longer plays, the number/play cell stands down, and the checkbox is
|
|
// the 14px accent disc the .slint actually specifies.
|
|
selectMode: root.selectMode
|
|
checked: root.checked
|
|
onToggleSelect: function (mods) { root.toggleSelect(mods) }
|
|
onPlayRequested: root.playRequested()
|
|
onEnqueueRequested: function (m) { root.enqueueRequested(m) }
|
|
}
|
|
|
|
// ⋯ menu button — the shared row's own gutter, re-drawn with the local
|
|
// action set behind it.
|
|
Rectangle {
|
|
id: moreBtn
|
|
x: root.width - 70
|
|
width: 32
|
|
height: 32
|
|
radius: theme.radiusSm
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: moreArea.containsMouse ? theme.surfaceElevated : "transparent"
|
|
QbzIcon {
|
|
name: "ellipsis"
|
|
width: 16
|
|
height: 16
|
|
anchors.centerIn: parent
|
|
tintName: moreArea.containsMouse ? "textPrimary" : "muted"
|
|
}
|
|
MouseArea {
|
|
id: moreArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: function (mouse) { root.openRowMenu(moreArea, mouse.x, mouse.y) }
|
|
}
|
|
}
|
|
|
|
// Source glyph (local / offline cache / Plex) — TrackRow.slint:709-727.
|
|
// Through controls/SourceIcon.qml, never QbzIcon: the Plex and Qobuz marks
|
|
// are MULTI-COLOUR and a tint flattens them to a silhouette. This row used
|
|
// to draw an accent-tinted `hard-drive` for Plex (a blue hard drive) and
|
|
// `cloud-download` for an offline copy, where the .slint draws the two
|
|
// brand marks.
|
|
SourceIcon {
|
|
visible: (root.item.source || "") !== ""
|
|
// `sourceRaw` FIRST (contract §D.1): it carries `qobuz_purchase`, which
|
|
// `source` folds into "offline" so the source chips still filter it.
|
|
// A purchased track's row is gold; a plain download's is not.
|
|
kind: root.item.sourceRaw || root.item.source || ""
|
|
// A dense ROW: the media marks draw monochrome and tinted, like the
|
|
// hard-drive beside them. Colour logos are for cards — a list of
|
|
// them fights the text it labels.
|
|
mono: true
|
|
// .slint:722 — local 15, the marks 16. `localTint` muted is the rule
|
|
// SourceGlyph.slint:6-8 calls load-bearing (never accent).
|
|
glyphSize: 15
|
|
plexSize: 16
|
|
qobuzSize: 16
|
|
localTint: "muted"
|
|
// The glyph's CENTRE stays where the 14px icon's was (x = width-20,
|
|
// centre = width-13) now that the marks are 15-16px wide.
|
|
x: Math.round(root.width - 13 - width / 2)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
// Right-click anywhere on the row opens the SAME menu at the pointer
|
|
// (TrackRow.slint's `open-track-menu(..., at-pointer: true)`). Declared
|
|
// last so it sits on top, and RIGHT-only so every left click still falls
|
|
// through to the row body / checkbox / ⋯ underneath.
|
|
//
|
|
// Suppressed in select mode — TrackRow.slint:201 gates the same gesture on
|
|
// `!root.multi-select-mode` ("a right-click toggles nothing there"), and
|
|
// the shared row's own trArea already does it (rows/TrackRow.qml:926).
|
|
// Disabled rather than hidden so the press falls through to the row body
|
|
// instead of landing on nothing.
|
|
MouseArea {
|
|
id: rcArea
|
|
anchors.fill: parent
|
|
enabled: !root.selectMode
|
|
acceptedButtons: Qt.RightButton
|
|
onClicked: function (mouse) { root.openRowMenu(rcArea, mouse.x, mouse.y) }
|
|
}
|
|
|
|
// LAZY. This is a QtQuick.Controls Popup with a Repeater over its
|
|
// entries, and it was built EAGERLY for every row — so a long list
|
|
// constructed one whole popup per visible row, and rebuilt them all
|
|
// on every scroll step, to show a menu only ever opened on click.
|
|
// Same lazy-Loader idiom this file already uses for the clipboard
|
|
// helper and the info modal.
|
|
Loader {
|
|
id: rowMenuLoader
|
|
active: false
|
|
sourceComponent: CardMenu {
|
|
menuWidth: 220
|
|
entries: {
|
|
var m = [
|
|
{ "label": QbzSession.tr("Play now", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
|
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
|
|
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
|
|
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
|
|
{ "label": QbzSession.tr("Add to mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "add-to-mixtape" },
|
|
{ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "action": "add-to-playlist" },
|
|
]
|
|
if (root.canGoAlbum) m.push({ "label": QbzSession.tr("Go to album", QbzSession.trRev), "icon": "disc-3", "action": "go-album" })
|
|
if (root.canGoArtist) m.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" })
|
|
m.push({ "label": QbzSession.tr("Track info", QbzSession.trRev), "icon": "info", "action": "track-info" })
|
|
if (root.canEditMetadata) m.push({ "label": QbzSession.tr("Edit metadata", QbzSession.trRev), "icon": "pen-line", "action": "edit-metadata" })
|
|
return m
|
|
}
|
|
onPicked: function (a) {
|
|
if (a === "play") root.playRequested()
|
|
// LOCAL-mode adds: one-element selection through the bulk entry
|
|
// point, which owns the only source-aware row -> ref resolver
|
|
// (see the file header).
|
|
else if (a === "add-to-playlist" || a === "add-to-mixtape"
|
|
|| a === "edit-metadata" || a === "track-info") {
|
|
if (root.nativeActions)
|
|
QbzLocal.tracksNativeRowAction(root.nativeIndex, a)
|
|
else
|
|
QbzLocal.bulkAction("track", JSON.stringify([String(root.item.id)]), a)
|
|
}
|
|
else if (a === "go-album") {
|
|
QbzLocal.openAlbum(root.item.albumId)
|
|
QbzShell.navigateTo("localalbum")
|
|
} else if (a === "go-artist") {
|
|
// Local/Plex artists have no catalog id — a NAME route into
|
|
// the Local Library Artists tab (local_album_actions.rs).
|
|
QbzLocal.openArtistByName(root.item.artist)
|
|
QbzShell.navigateTo("local")
|
|
} else {
|
|
root.enqueueRequested(a)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/// Build the popup on first use, then open it.
|
|
function openRowMenu(anchor, x, y) {
|
|
rowMenuLoader.active = true
|
|
rowMenuLoader.item.openAtCursor(anchor, x, y)
|
|
}
|
|
}
|