Files
qbz/crates/qbz-qt/qml/views/local/LocalTrackRow.qml
T
vicrodh 3cfce8a769 feat(qt): the media-server settings panel, driven and verified in the window
ONE component instantiated twice, because it is one form twice. Every
difference between the protocols is a property set at the call site, so
they are visible in one place instead of drifting across two files:
"Test" proves the address for Jellyfin and only "a Subsonic server
answered" for Subsonic (which has no credential-free endpoint), the
password is discarded for Jellyfin and kept for Subsonic (no session,
md5(password+salt) per request), and the sync cost is stated up front —
45.8 s per 4924 tracks against 0.81 s per 6678 — so a long sweep does not
read as a hang. The credential never leaves Rust; the panel gets
`hasCredential`.

Assets: the colour marks for cards, the monochrome ones for rows. Both
mono files arrived as BLACK ink and are invisible on a dark theme
untinted, so they were normalised to the #ffffff master ink and joined
`icon_tint_qt::MASTERS` — the port's only shader-free way to recolour an
SVG per theme. The Jellyfin colour asset had a white rounded-rect plate
behind the mark; it was stripped, because every other brand mark here is
transparent and a white tile beside them reads as a rendering bug.
Rendered with Qt itself and looked at before any of it was wired.

CAVEAT, recorded in SourceIcon: the Subsonic mark is NAVIDROME's logo,
and `subsonic` covers Gonic, Airsonic, Astiga and Ampache too. `ping`
reports the server's own type and it is already persisted, so branching
later is cheap — a known wrongness, not an unnoticed one.

THREE BUGS THAT ONLY THE RUNNING WINDOW COULD SHOW, all found by
driving it under Xvfb rather than by reading the log:

1. `MediaServerSettings is not a type` — a .qml missing from build.rs is
   not in the QML module. It resolves fine on disk, compiles nothing,
   and blanks the WHOLE settings page at runtime. All three existing
   audits passed. `qml_module_registration_audit.py` is the missing
   check and is now in the gate.
2. The toggle would not move. The panel reads its state from the
   settings DOCUMENT, unlike Plex's toggle which reads a bridge
   property, so every mutation has to republish it — connect, enable,
   disconnect and the end of a sweep now do.
3. Every Jellyfin album in the grid drew the LOCAL HARD-DRIVE badge:
   `local_rows::badge_source` had never been taught the two words and
   folded them into `_ => local`. Not cosmetic — that string is what the
   source chips filter on.

`play_single_from_source` also grew the arm it needed: a media-server row
from the recently-played rail has no library.db row to mint a LocalTrack
from, so it resolves through `registry.tracks_of` instead.

END TO END, from the UI, against the real server: toggle on -> type the
address -> Connect -> "jellyfin sync: 500/4924 ... 4924 saved, 0 pruned,
4924 cached" -> Local Library shows 461 albums with their covers and the
Jellyfin badge. The cached quality distribution is identical to what the
server reported in the original protocol sweep.

428/428.

cc
2026-08-20 10:57:28 -06:00

269 lines
13 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
/// 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)
signal toggleSelect()
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 })
// 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 || "") !== ""
// 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"
// 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: root.toggleSelect()
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" })
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") {
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)
}
}