Integrate the Kiosk profile hardening round: 12 dedicated Kiosk views plus 22 shared opt-in hosts, shared navigation history with Back/Forward, Albums as the default library tab, native paged Local Library (artists/tracks), lightweight MyQBZ grid and detail, Ephemeral lifecycle, bounded lists and artwork sized to the physical frame, static skeletons, 64px transport bar, Settings touch reflow and a persistent Immersive exit. Desktop keeps its prior behavior: every shared component defaults to the desktop path and only opts into Kiosk behavior through explicit properties (origin-relative ScrollMemory, viewport-bounded artwork requests). Regression coverage: kiosk navigation and artwork Node tests added to the runtime gate, scroll-origin fixtures for fixed and grouped rows, i18n strings for all eight locales. Verified by 750 release tests, six QML audits, Kiosk smokes at 800x480 and 1280x800 and a Desktop A/B with seven pixel-identical captures. cc
303 lines
15 KiB
QML
303 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 {
|
|
property bool kioskHost: false
|
|
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: kioskHost ? 64 : 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 { kioskHost: root.kioskHost;
|
|
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 - (root.kioskHost ? 58 : 46)
|
|
item: root.rowItem
|
|
number: root.number
|
|
showArtwork: root.showArtwork
|
|
showAlbum: root.showAlbum
|
|
showFavorite: false
|
|
showDownload: false
|
|
showMenu: false
|
|
// Local rows drag through the LOCAL arm: item.id is a local DB row
|
|
// id, so the shared drag carries it typed (`dragStartLocal`) and the
|
|
// drop handlers resolve it source-aware — the same guardrails as the
|
|
// picker. `draggable` stays false so `catalogRow` keeps meaning
|
|
// "item.id is a Qobuz catalog id".
|
|
draggable: false
|
|
// NOT for native-catalog rows: their `item.id` is the catalog seam's
|
|
// identity, not a legacy row id the drop resolver can look up — a
|
|
// ghost that lands as a silent no-op is worse than no gesture.
|
|
localDragSource: !root.nativeActions
|
|
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 - (root.kioskHost ? 82 : 70)
|
|
width: root.kioskHost ? 44 : 32
|
|
height: root.kioskHost ? 44 : 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 { kioskHost: root.kioskHost;
|
|
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)
|
|
}
|
|
}
|