`mixtape_collection_items.artwork_url` is a SNAPSHOT written once at add time. The grid tile and the hero mosaic read that column and stop — only the detail row has a backfill — so an item stored without a cover is blank on those surfaces for the life of the row. WHAT THE DATA SAID, against two hypotheses including mine. The owner reported albums added "from Qobuz" arriving coverless, and the obvious suspects were a narrow ImageSet read and the disco builder. Querying the live library.db settled it: 8 of 35 rows have an empty artwork_url and every one of them is `item_type='album' source='local'`, while every Qobuz album row carries a url. So the confirmed gap is the LOCAL album page's cassette button, which passed `artwork_url: None` outright — the only call site in the app that could produce those rows. It now stamps the album's own cover path, which is correct to store here: the mosaic passes local paths through unrewritten, and that is what lets a collection of local albums render with no network at all. Also stamped, where the remote url was already in hand and only an empty string was being sent: Search, Playlist, Library, Artist and Label track rows. AlbumView takes the HEADER's url rather than the row's — album-view track rows are published with an empty artUrl by construction (album_qt.rs says so in the field's own doc), so the obvious edit there would have been a no-op, and every track of an album shares its cover. Four sites still send nothing and now SAY why in place: the three now-playing bars and MixView have only a file:// cache path or no url at all, and a cache path must never be stored — other machines read this column. Those need a remote-url field on their documents first. The `image.best()` widening in album_qt/bulk_tracks_qt rides along as hygiene, not as the fix: `thumbnail.or(small)` was the outlier in a file that reads every other cover through `best()`. An adversarial pass over 494 real ImageSets found ZERO albums lacking both fields, so it was not what the owner hit — it is simply the read the rest of the file already agreed on. STILL OPEN: if the owner's two albums were Qobuz after all, their urls ARE stored, and the symptom is on the render side — the mosaics have no fallback where the detail row does. cc
1063 lines
43 KiB
QML
1063 lines
43 KiB
QML
// KioskNowPlaying — the kiosk Now Playing centerpiece (QML port of
|
|
// crates/qbz-ui/ui/shell/KioskNowPlaying.slint, 474 lines).
|
|
//
|
|
// A full-screen 58/42 split with a 1px divider: the current track on the LEFT
|
|
// (a Cover<->Lyrics toggle over the big art, then title / artist link / seek /
|
|
// the 7-control transport with the quality stamp floating at its right end)
|
|
// and the queue on the RIGHT (Up Next / History tabs over a windowed row
|
|
// list). It reads the same bridges the desktop surfaces read — QbzPlayer,
|
|
// QbzQueue, QbzLyrics — nothing is mirrored.
|
|
//
|
|
// THE 58 IS A REMAINDER, NOT A CONSTANT (KioskNowPlaying.slint:370): the right
|
|
// panel is 42% of (width - 1) and the left takes what is left, so the two can
|
|
// never disagree by a rounding pixel and leave a seam over the divider.
|
|
//
|
|
// THREE THINGS THE SHELL DELEGATES TO THIS VIEW:
|
|
// 1. The PLAYER-ZONE RING is drawn HERE, around the in-page transport
|
|
// (:333-338). KioskShell hides both the small transport bar and its own
|
|
// player ring on this route (KioskShell.qml:452,497), so without this the
|
|
// player zone would be invisible while focused.
|
|
// 2. The queue-panel refresh (:170) — the paginated slice is only rebuilt on
|
|
// demand.
|
|
// 3. The lyrics-follow gate (:173,202,212). In Slint that flag is
|
|
// `ShellState.kiosk-lyrics-follow`, read by the Rust sync engine; in Qt
|
|
// the engine IS QML (shell/LyricsSyncEngine.qml) and its `gateOpen`
|
|
// property is the same boolean, so the flag is the binding below rather
|
|
// than a bridge member (contract §8.5 / trap 2). Because `gateOpen`
|
|
// includes `showLyrics`, mounting in Cover mode resets it structurally —
|
|
// the explicit assignment in Component.onCompleted is the belt to that
|
|
// brace, for a Loader that keeps this item alive across remounts.
|
|
//
|
|
// NAV: the model is the RIGHT panel only — 2 queue tabs followed by the active
|
|
// tab's rows as a 1-column list (:135-143). The left panel (the Cover/Lyrics
|
|
// pair, the artist link, the seek rail) is TOUCH-ONLY and carries no ring
|
|
// (:132-134); the transport is the separate "player" zone.
|
|
//
|
|
// Slint's `viewport-y` is negative as you scroll down; Qt's `contentY` is its
|
|
// positive twin — every occurrence is already translated, and both scroll
|
|
// writes run through Qt.callLater so they land on a settled layout.
|
|
//
|
|
// The seek bar is INLINED rather than extracted to a shared SeekBar.qml: the
|
|
// port has no such component (the desktop bars inline their own) and adding
|
|
// one is a new file, which is a build.rs change this block does not own. The
|
|
// geometry below is SeekBar.slint's `show-times: true` arm, number for number.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../controls"
|
|
import "../shell"
|
|
import "../theme"
|
|
|
|
Rectangle {
|
|
id: root
|
|
|
|
// KioskNowPlaying.slint:120 sets no background — the shell's content panel
|
|
// paints the surface behind the left half, the right panel paints its own.
|
|
color: "transparent"
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
// ---- View-local state ------------------------------------------------
|
|
// The left panel's Cover(false) / Lyrics(true) mode (:127).
|
|
property bool showLyrics: false
|
|
// Up Next (0) / History (1). Slint reads QueueState.tab, an app-global;
|
|
// the Qt queue bridge has no tab field and the desktop panel keeps its own
|
|
// (shell/QueuePanel.qml:32), so this view does the same. Leaving the view
|
|
// therefore resets it to Up Next, which is the desktop panel's behaviour.
|
|
property int queueTab: 0
|
|
|
|
// ---- Queue document --------------------------------------------------
|
|
readonly property var doc: root.parseQueue()
|
|
function parseQueue() {
|
|
try {
|
|
return JSON.parse(QbzQueue.queueJson)
|
|
} catch (e) {
|
|
return ({})
|
|
}
|
|
}
|
|
// `upcoming` is ALREADY the paginated page slice (queue_qt.rs:471), which
|
|
// is why mounting the whole list here is cheap.
|
|
readonly property var upcoming: root.doc.upcoming || []
|
|
readonly property var historyRows: root.doc.history || []
|
|
readonly property var activeRows: root.queueTab === 0 ? root.upcoming : root.historyRows
|
|
|
|
// ---- Lyrics document -------------------------------------------------
|
|
readonly property var lyricsDoc: root.parseLyrics()
|
|
function parseLyrics() {
|
|
try {
|
|
return JSON.parse(QbzLyrics.docJson)
|
|
} catch (e) {
|
|
return ({})
|
|
}
|
|
}
|
|
readonly property var lyricsLines: root.lyricsDoc.lines ? root.lyricsDoc.lines : []
|
|
readonly property bool lyricsSynced: root.lyricsDoc.synced === true
|
|
readonly property int lyricsStatus: root.lyricsDoc.status !== undefined
|
|
? root.lyricsDoc.status : 0
|
|
// The mount gate (:248). Nothing else may mount the karaoke view: the
|
|
// reference records a Slint relayout panic behind that rule, and while the
|
|
// Qt engine has no such footgun the GATE IS THE CONTRACT — same condition,
|
|
// same three fallbacks.
|
|
readonly property bool lyricsReady: root.lyricsStatus === 2 && root.lyricsLines.length > 0
|
|
|
|
// The three fallback strings (:266-276), in the source's evaluation order.
|
|
// Note what is deliberately NOT here, and must not be added: status 4
|
|
// (error) shows the plain "No lyrics available" rather than the backend
|
|
// error string, status 2 with zero lines shows "Fetching lyrics...", and
|
|
// there is no LRCLIB contribution link and no spinner. The desktop
|
|
// shell/LyricsPanel.qml does all four differently on purpose.
|
|
readonly property string lyricsFallback: root.lyricsStatus === 5
|
|
? QbzSession.tr("Lyrics unavailable offline", QbzSession.trRev)
|
|
: (root.lyricsStatus === 3 || root.lyricsStatus === 4)
|
|
? QbzSession.tr("No lyrics available", QbzSession.trRev)
|
|
: QbzSession.tr("Fetching lyrics...", QbzSession.trRev)
|
|
|
|
// ---- Nav geometry (:135-143) ----------------------------------------
|
|
// 2 leading tab entries + the ACTIVE tab's rows, one column. The clamp of
|
|
// `index` into the new count lives in the bridge (kiosk_nav_qt).
|
|
function publishNav() {
|
|
QbzKioskNav.publishNav(2, 1,
|
|
2 + (root.queueTab === 0 ? root.upcoming.length : root.historyRows.length),
|
|
false)
|
|
}
|
|
// :148 — one probe over BOTH lengths, so a republish of either list
|
|
// refreshes the count even while the other tab is showing.
|
|
readonly property int navLenProbe: root.upcoming.length + root.historyRows.length
|
|
onNavLenProbeChanged: root.publishNav()
|
|
onQueueTabChanged: {
|
|
root.publishNav()
|
|
// Slint mounts a FRESH Flickable per tab (:404,:438), so each switch
|
|
// starts at the top. One instance per tab lives for the whole view
|
|
// here, so the reset is explicit.
|
|
upFlick.contentY = 0
|
|
histFlick.contentY = 0
|
|
}
|
|
|
|
readonly property int focusedItem: QbzKioskNav.index - QbzKioskNav.tabs
|
|
readonly property bool itemFocused: QbzKioskNav.navActive
|
|
&& QbzKioskNav.zone === "content"
|
|
&& root.focusedItem >= 0
|
|
&& root.focusedItem < root.activeRows.length
|
|
|
|
// Scroll the focused row into view (:416-425 / :448-457). 60px pitch =
|
|
// the 58px row + the 2px column spacing; ±8px of air at either edge.
|
|
function scrollFocusIntoView() {
|
|
if (!root.itemFocused)
|
|
return
|
|
var flick = root.queueTab === 0 ? upFlick : histFlick
|
|
var top = root.focusedItem * 60
|
|
if (top < flick.contentY)
|
|
flick.contentY = top - 8
|
|
else if (top + 58 > flick.contentY + flick.height)
|
|
flick.contentY = top + 58 - flick.height + 8
|
|
}
|
|
|
|
Connections {
|
|
target: QbzKioskNav
|
|
|
|
function onIndexChanged() {
|
|
Qt.callLater(root.scrollFocusIntoView)
|
|
}
|
|
|
|
// Enter pulse (:154-167): on a tab it switches tabs, on a row it plays
|
|
// that row. The `- 2` is the source's literal, matching `tabs = 2`.
|
|
function onActivateSeqChanged() {
|
|
if (!QbzKioskNav.navActive || QbzKioskNav.zone !== "content")
|
|
return
|
|
if (QbzKioskNav.index < QbzKioskNav.tabs) {
|
|
root.queueTab = QbzKioskNav.index
|
|
return
|
|
}
|
|
var i = QbzKioskNav.index - 2
|
|
if (root.queueTab === 0 && i < root.upcoming.length)
|
|
QbzQueue.queuePlayUpcoming(i)
|
|
else if (root.queueTab === 1 && i < root.historyRows.length)
|
|
QbzQueue.queuePlayHistory(i)
|
|
}
|
|
}
|
|
|
|
// ---- Queue-row covers (Qt-only plumbing) -----------------------------
|
|
// Slint's QueueItem carries a resolved `artwork` image the pipeline pushes
|
|
// into the shared model; the Qt QueueRow carries only the remote `artUrl`,
|
|
// so the urls are dispatched into the shared artwork pipeline and resolved
|
|
// out of `coverMap` when each row is built.
|
|
//
|
|
// Arrivals are coalesced into ONE rebind per frame (the ArtistView fix,
|
|
// repeated in kiosk/KioskArtist.qml): on a warm cache the whole disk-hit
|
|
// set lands in a single synchronous loop, and rebinding per arrival is
|
|
// quadratic in the page.
|
|
property var coverMap: ({})
|
|
property var _coverInbox: ({})
|
|
property var dispatchedCovers: ({})
|
|
|
|
Timer {
|
|
id: coverFlush
|
|
interval: 16
|
|
repeat: false
|
|
onTriggered: {
|
|
var m = Object.assign({}, root.coverMap, root._coverInbox)
|
|
root._coverInbox = ({})
|
|
// A rebind needs a NEW object reference (same-ref is not a change).
|
|
root.coverMap = m
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: QbzLibrary
|
|
// `sidebarArtworkWindow` keys its replies by the REQUESTED url.
|
|
function onLibraryArtworkReady(key, path) {
|
|
root._coverInbox[key] = path
|
|
if (!coverFlush.running)
|
|
coverFlush.start()
|
|
}
|
|
}
|
|
|
|
// Re-derives the rows from `root.doc` rather than from `upcoming` /
|
|
// `historyRows`: called from onDocChanged those derived properties still
|
|
// hold the PREVIOUS document, so every request would be exactly one
|
|
// publish behind and the rows on screen would never have their cover asked
|
|
// for (the measured QueuePanel.qml:164-179 defect).
|
|
function dispatchCovers() {
|
|
var d = root.doc
|
|
var up = d.upcoming || []
|
|
var hist = d.history || []
|
|
var urls = []
|
|
var i
|
|
for (i = 0; i < up.length; i++)
|
|
if (up[i].artUrl && !root.dispatchedCovers[up[i].artUrl]) {
|
|
root.dispatchedCovers[up[i].artUrl] = true
|
|
urls.push(up[i].artUrl)
|
|
}
|
|
for (i = 0; i < hist.length; i++)
|
|
if (hist[i].artUrl && !root.dispatchedCovers[hist[i].artUrl]) {
|
|
root.dispatchedCovers[hist[i].artUrl] = true
|
|
urls.push(hist[i].artUrl)
|
|
}
|
|
if (urls.length > 0)
|
|
QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
|
|
}
|
|
|
|
onDocChanged: root.dispatchCovers()
|
|
|
|
// ---- Mount side effects (:169-175) -----------------------------------
|
|
Component.onCompleted: {
|
|
QbzQueue.queuePanelOpened()
|
|
root.showLyrics = false
|
|
root.publishNav()
|
|
root.dispatchCovers()
|
|
}
|
|
|
|
// ---- Lyrics sync ------------------------------------------------------
|
|
// THIS BINDING IS `kiosk-lyrics-follow`. Slint writes the flag true when
|
|
// the Lyrics tab is picked (:212) and false on the Cover tab (:202) and on
|
|
// mount (:173); the same three transitions fall out of `showLyrics` here.
|
|
LyricsSyncEngine {
|
|
id: kioskSync
|
|
gateOpen: root.showLyrics && root.lyricsReady && root.lyricsSynced
|
|
}
|
|
|
|
// Every doc commit re-lands the ladder on the right line even while paused
|
|
// (shell/LyricsPanel.qml:60-66).
|
|
Connections {
|
|
target: QbzLyrics
|
|
function onDocJsonChanged() {
|
|
kioskSync.reset()
|
|
kioskSync.kick()
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: lyricsViewComponent
|
|
|
|
LyricsLinesView {
|
|
lines: root.lyricsLines
|
|
synced: root.lyricsSynced
|
|
// Slint's row reads LyricsState.show-translation itself
|
|
// (LyricsLinesView.slint:271); the Qt view takes it from the host.
|
|
showTranslation: QbzLyrics.showTranslation
|
|
sync: kioskSync
|
|
sidePadding: 8
|
|
lineGap: 13
|
|
sizeInactive: 19
|
|
sizeActive: 23
|
|
dimmingMode: QbzLyrics.dimmingMode
|
|
// Hardcoded true at :256 — the kiosk surface always follows,
|
|
// whatever the display pref says.
|
|
autoFollow: true
|
|
uppercase: QbzLyrics.uppercase
|
|
// The Qt view takes the INDEX and maps it to a family itself
|
|
// (LyricsLinesView.qml:66-69), which is the same map as :259-263.
|
|
fontIndex: QbzLyrics.fontIndex
|
|
// Hardcoded accent at :264 — not the custom lyrics swatch.
|
|
activeColor: theme.accent
|
|
// Slint computes `reduce-motion || lite-fill` inside the row
|
|
// (LyricsLinesView.slint:232); kiosk forces reduce-motion.
|
|
liteFill: QbzLyrics.liteFill || QbzShell.reduceMotion
|
|
}
|
|
}
|
|
|
|
// ---- Track Info + "Add to…" hosts ------------------------------------
|
|
// TransportControls owns neither surface (it emits trackInfoRequested /
|
|
// addRequested and the HOST mounts them, shell/TransportControls.qml:29,31),
|
|
// so the two hosts are replicated from shell/PlayerBar.qml. Without them
|
|
// the (i) and "+" buttons would be inert affordances.
|
|
function openTrackInfo() {
|
|
if (!QbzPlayer.npHasTrack)
|
|
return
|
|
var id = QbzPlayer.npTrackId
|
|
if (!/^[0-9]+$/.test(id))
|
|
return
|
|
trackInfo.openFor(id)
|
|
}
|
|
|
|
TrackInfoModal { id: trackInfo }
|
|
|
|
// Favourite state of the now-playing track: the queue document's `current`
|
|
// row, with a one-slot override so a heart flipped on ANY other surface
|
|
// reaches this transport (PlayerBar.qml:128-160).
|
|
property string favOverrideId: ""
|
|
property bool favOverrideValue: false
|
|
readonly property bool npFavorite: {
|
|
if (root.favOverrideId !== "" && root.favOverrideId === QbzPlayer.npTrackId)
|
|
return root.favOverrideValue
|
|
return !!(root.doc.current && root.doc.current.isFavorite)
|
|
}
|
|
Connections {
|
|
target: QbzLibrary
|
|
function onLibraryFavoriteChanged(key, value) {
|
|
var id = QbzPlayer.npTrackId
|
|
if (id !== "" && key === "track:" + id) {
|
|
root.favOverrideId = id
|
|
root.favOverrideValue = value
|
|
}
|
|
}
|
|
}
|
|
|
|
// The provenance word the MyQBZ payload needs ("qobuz" | "" = unknown).
|
|
// The id guard keeps a stale queue document from lending the previous
|
|
// track's provenance to this one (PlayerBar.qml:162-194).
|
|
readonly property string npSource: {
|
|
var cur = root.doc.current
|
|
if (cur && cur.id === QbzPlayer.npTrackId)
|
|
return cur.isLocal === true ? "" : "qobuz"
|
|
return ""
|
|
}
|
|
|
|
CardMenu {
|
|
id: addMenu
|
|
menuWidth: 232
|
|
entries: {
|
|
var m = [
|
|
{
|
|
"label": root.npFavorite
|
|
? QbzSession.tr("Remove from library", QbzSession.trRev)
|
|
: QbzSession.tr("Add to library", QbzSession.trRev),
|
|
"icon": root.npFavorite ? "heart-filled" : "heart",
|
|
"action": "favorite"
|
|
},
|
|
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
|
|
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
|
|
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
|
|
]
|
|
// "Add to playlist" sits SECOND (TransportControls.slint:143), and
|
|
// rides the npSource gate: unknown provenance -> the entry is
|
|
// ABSENT, never rendered-and-inert.
|
|
if (root.npSource === "qobuz") {
|
|
m.splice(1, 0, {
|
|
"label": QbzSession.tr("Add to playlist", QbzSession.trRev),
|
|
"icon": "list-music",
|
|
"action": "playlist"
|
|
})
|
|
}
|
|
if (root.npSource !== "") {
|
|
m.push({
|
|
"label": QbzSession.tr("Add to mixtape", QbzSession.trRev),
|
|
"icon": "cassette-tape",
|
|
"action": "mixtape"
|
|
})
|
|
}
|
|
if (QbzPlayer.npAlbumId !== "") {
|
|
m.push({
|
|
"label": QbzSession.tr("Add album to collection", QbzSession.trRev),
|
|
"icon": "disc-3",
|
|
"action": "album-favorite"
|
|
})
|
|
}
|
|
return m
|
|
}
|
|
onPicked: function (a) {
|
|
var id = QbzPlayer.npTrackId
|
|
if (a === "favorite") {
|
|
if (id !== "") QbzQueue.queueToggleFavorite("track", id)
|
|
} else if (a === "queue" || a === "later" || a === "next") {
|
|
if (id !== "") QbzPlayer.enqueueTrack(id, a)
|
|
} else if (a === "playlist") {
|
|
if (id !== "") QbzPlaylistPicker.openForTrack(id)
|
|
} else if (a === "album-favorite") {
|
|
if (QbzPlayer.npAlbumId !== "")
|
|
QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId)
|
|
} else if (a === "mixtape") {
|
|
// `npArtworkPath` is a file:// CACHE path, so it is NOT the
|
|
// artworkUrl — the store would keep a dead local path.
|
|
if (id !== "" && root.npSource !== "")
|
|
QbzMyQbzAdd.open(JSON.stringify([{
|
|
"itemType": "track", "source": root.npSource,
|
|
"sourceItemId": id, "title": QbzPlayer.npTitle,
|
|
// artworkUrl STAYS EMPTY here, deliberately: the player bridge exposes only np_artwork_path.
|
|
// A file:// cache path must NOT be stored — the collection's
|
|
// artwork_url is a snapshot other machines read — so this needs a
|
|
// remote-url field on the document first. The five sister sites
|
|
// that HAD one were stamped 2026-08-22.
|
|
"subtitle": QbzPlayer.npArtist, "artworkUrl": "",
|
|
"year": null, "trackCount": null
|
|
}]))
|
|
}
|
|
}
|
|
}
|
|
|
|
function fmt(secs) {
|
|
var m = Math.floor(secs / 60)
|
|
var s = Math.floor(secs % 60)
|
|
return m + ":" + (s < 10 ? "0" : "") + s
|
|
}
|
|
function clamp01(v) {
|
|
return Math.min(Math.max(v, 0), 1)
|
|
}
|
|
|
|
// =====================================================================
|
|
// RIGHT — the queue (:369-472). Declared first so the divider and the
|
|
// left panel can anchor off it: the right width is the fixed term and
|
|
// the left is the remainder (:370).
|
|
// =====================================================================
|
|
Rectangle {
|
|
id: rightPanel
|
|
anchors.right: root.right
|
|
anchors.top: root.top
|
|
anchors.bottom: root.bottom
|
|
width: Math.round((root.width - 1) * 0.42)
|
|
color: theme.surfaceCard
|
|
|
|
// Tabs row (:375-395): 30px tall, 20px apart, left-aligned.
|
|
Row {
|
|
id: queueTabsRow
|
|
x: 18
|
|
y: 18
|
|
height: 30
|
|
spacing: 20
|
|
|
|
QueueTab {
|
|
height: queueTabsRow.height
|
|
label: QbzSession.tr("Up Next", QbzSession.trRev)
|
|
active: root.queueTab === 0
|
|
navFocused: QbzKioskNav.navActive
|
|
&& QbzKioskNav.zone === "content"
|
|
&& QbzKioskNav.index === 0
|
|
onPicked: root.queueTab = 0
|
|
}
|
|
QueueTab {
|
|
height: queueTabsRow.height
|
|
label: QbzSession.tr("History", QbzSession.trRev)
|
|
active: root.queueTab === 1
|
|
navFocused: QbzKioskNav.navActive
|
|
&& QbzKioskNav.zone === "content"
|
|
&& QbzKioskNav.index === 1
|
|
onPicked: root.queueTab = 1
|
|
}
|
|
}
|
|
|
|
// Body (:402-470): a Flickable whose content height IS the column's,
|
|
// so short lists top-anchor. (The reference's note: a ListView at
|
|
// height 100% bottom-anchors a short list.)
|
|
Item {
|
|
id: queueBody
|
|
x: 18
|
|
y: queueTabsRow.y + queueTabsRow.height + 12
|
|
width: Math.max(0, rightPanel.width - 36)
|
|
height: Math.max(0, rightPanel.height - 18 - queueBody.y)
|
|
|
|
Flickable {
|
|
id: upFlick
|
|
anchors.fill: queueBody
|
|
visible: root.queueTab === 0
|
|
contentWidth: upFlick.width
|
|
contentHeight: upColumn.height
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
Column {
|
|
id: upColumn
|
|
width: upFlick.width
|
|
spacing: 2
|
|
|
|
Repeater {
|
|
// The hidden tab's rows are genuinely unmounted, which
|
|
// is what the reference's `if` does — a history list is
|
|
// unbounded and a Pi cannot afford it off-screen.
|
|
model: root.queueTab === 0 ? root.upcoming : []
|
|
|
|
delegate: QueueRowLite {
|
|
required property var modelData
|
|
required property int index
|
|
|
|
width: upColumn.width
|
|
item: modelData
|
|
artPath: root.coverMap[modelData.artUrl] || ""
|
|
navFocused: root.itemFocused && root.focusedItem === index
|
|
onPlay: QbzQueue.queuePlayUpcoming(index)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Flickable {
|
|
id: histFlick
|
|
anchors.fill: queueBody
|
|
visible: root.queueTab === 1
|
|
contentWidth: histFlick.width
|
|
contentHeight: histColumn.height
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
Column {
|
|
id: histColumn
|
|
width: histFlick.width
|
|
spacing: 2
|
|
|
|
Repeater {
|
|
model: root.queueTab === 1 ? root.historyRows : []
|
|
|
|
delegate: QueueRowLite {
|
|
required property var modelData
|
|
required property int index
|
|
|
|
width: histColumn.width
|
|
item: modelData
|
|
artPath: root.coverMap[modelData.artUrl] || ""
|
|
navFocused: root.itemFocused && root.focusedItem === index
|
|
onPlay: QbzQueue.queuePlayHistory(index)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The 1px rule between the panels (:362-365).
|
|
Rectangle {
|
|
id: divider
|
|
anchors.right: rightPanel.left
|
|
anchors.top: root.top
|
|
anchors.bottom: root.bottom
|
|
width: 1
|
|
color: theme.borderSubtle
|
|
}
|
|
|
|
// =====================================================================
|
|
// LEFT — the current track (:181-360). 18px padding, 12px spacing.
|
|
// =====================================================================
|
|
Item {
|
|
id: leftPanel
|
|
anchors.left: root.left
|
|
anchors.right: divider.left
|
|
anchors.top: root.top
|
|
anchors.bottom: root.bottom
|
|
|
|
// Cover / Lyrics toggle (:192-215), pinned to the TOP so it lines up
|
|
// with the Up Next / History tabs across the divider. TOUCH-ONLY:
|
|
// neither tab sets navFocused (:82-84).
|
|
Row {
|
|
id: toggleRow
|
|
x: 18
|
|
y: 18
|
|
spacing: 18
|
|
|
|
QueueTab {
|
|
label: QbzSession.tr("Cover", QbzSession.trRev)
|
|
active: !root.showLyrics
|
|
onPicked: root.showLyrics = false
|
|
}
|
|
QueueTab {
|
|
label: QbzSession.tr("Lyrics", QbzSession.trRev)
|
|
active: root.showLyrics
|
|
onPicked: root.showLyrics = true
|
|
}
|
|
}
|
|
|
|
// The flexible art / lyrics area (:221-278) — it takes ALL the slack
|
|
// between the pinned toggle above and the pinned transport below, and
|
|
// CLIPS, so on a short window (1080x600) the cover shrinks instead of
|
|
// pushing the controls off-screen.
|
|
Item {
|
|
id: artArea
|
|
x: 18
|
|
y: toggleRow.y + toggleRow.height + 12
|
|
width: Math.max(0, leftPanel.width - 36)
|
|
height: Math.max(0, bottomBlock.y - 12 - artArea.y)
|
|
clip: true
|
|
|
|
// CONTAIN, not crop (:233): the cover is as large as fits and
|
|
// scales with the window without reading parent geometry.
|
|
RoundedImage {
|
|
anchors.fill: artArea
|
|
visible: !root.showLyrics
|
|
source: QbzPlayer.npArtworkPath
|
|
radius: 0
|
|
fit: "contain"
|
|
}
|
|
|
|
Item {
|
|
id: lyricsArm
|
|
anchors.fill: artArea
|
|
visible: root.showLyrics
|
|
|
|
Loader {
|
|
anchors.fill: lyricsArm
|
|
active: root.showLyrics && root.lyricsReady
|
|
sourceComponent: lyricsViewComponent
|
|
}
|
|
|
|
Text {
|
|
anchors.fill: lyricsArm
|
|
visible: !root.lyricsReady
|
|
text: root.lyricsFallback
|
|
color: theme.textMuted
|
|
font.pixelSize: 14
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pinned metadata + transport (:284-358): natural height at the
|
|
// bottom, so ALL the slack goes to the art area above.
|
|
Column {
|
|
id: bottomBlock
|
|
x: 18
|
|
y: leftPanel.height - 18 - bottomBlock.height
|
|
width: Math.max(0, leftPanel.width - 36)
|
|
spacing: 8
|
|
|
|
Text {
|
|
width: bottomBlock.width
|
|
text: QbzPlayer.npTitle
|
|
color: theme.textPrimary
|
|
font.pixelSize: 20
|
|
font.weight: theme.weightBold
|
|
horizontalAlignment: Text.AlignHCenter
|
|
elide: Text.ElideRight
|
|
wrapMode: Text.NoWrap
|
|
maximumLineCount: 1
|
|
}
|
|
|
|
// The WHOLE 18px row is the artist link (:297-313). Touch-only.
|
|
Item {
|
|
id: artistRow
|
|
width: bottomBlock.width
|
|
height: 18
|
|
|
|
Text {
|
|
id: artistLabel
|
|
anchors.centerIn: artistRow
|
|
text: QbzPlayer.npArtist
|
|
color: theme.textSecondary
|
|
font.pixelSize: 14
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: artistRow
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: QbzArtist.openArtist(QbzPlayer.npArtistId)
|
|
}
|
|
}
|
|
|
|
// ---- Seek bar (SeekBar.slint, show-times arm) ----------------
|
|
Item {
|
|
id: seekRow
|
|
width: bottomBlock.width
|
|
height: 20
|
|
|
|
Text {
|
|
id: elapsedLabel
|
|
anchors.left: seekRow.left
|
|
anchors.verticalCenter: seekRow.verticalCenter
|
|
width: 44
|
|
horizontalAlignment: Text.AlignRight
|
|
text: root.fmt(QbzPlayer.npElapsedSecs)
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
}
|
|
|
|
Text {
|
|
id: remainingLabel
|
|
anchors.right: seekRow.right
|
|
anchors.verticalCenter: seekRow.verticalCenter
|
|
width: 44
|
|
horizontalAlignment: Text.AlignLeft
|
|
text: "-" + root.fmt(Math.max(0, QbzPlayer.npDurationSecs - QbzPlayer.npElapsedSecs))
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
}
|
|
|
|
Rectangle {
|
|
id: seekTrack
|
|
anchors.left: elapsedLabel.right
|
|
anchors.leftMargin: 12
|
|
anchors.right: remainingLabel.left
|
|
anchors.rightMargin: 12
|
|
anchors.verticalCenter: seekRow.verticalCenter
|
|
height: 3
|
|
radius: 2
|
|
color: theme.surfaceElevated
|
|
|
|
// Buffered / cache line — text-muted @0.35, NOT
|
|
// border-muted (an alpha-over-surface token, which inverts
|
|
// against the elevated rail on light themes).
|
|
Rectangle {
|
|
width: seekTrack.width * root.clamp01(QbzPlayer.npCacheProgress)
|
|
height: seekTrack.height
|
|
radius: 2
|
|
color: theme.textMuted
|
|
opacity: 0.35
|
|
}
|
|
Rectangle {
|
|
width: seekTrack.width * root.clamp01(QbzPlayer.npProgress)
|
|
height: seekTrack.height
|
|
radius: 2
|
|
color: theme.accent
|
|
}
|
|
// Hover thumb.
|
|
Rectangle {
|
|
width: 12
|
|
height: 12
|
|
radius: 6
|
|
color: theme.textPrimary
|
|
anchors.verticalCenter: seekTrack.verticalCenter
|
|
x: seekTrack.width * root.clamp01(QbzPlayer.npProgress) - width / 2
|
|
opacity: seekArea.containsMouse ? 1.0 : 0.0
|
|
Behavior on opacity {
|
|
NumberAnimation { duration: 100 }
|
|
}
|
|
}
|
|
|
|
// Tall, easy-to-grab hit area over the thin rail. The seek
|
|
// target is LOCKED to what has downloaded while streaming,
|
|
// and the cursor turns not-allowed past it (:93-99).
|
|
MouseArea {
|
|
id: seekArea
|
|
anchors.left: seekTrack.left
|
|
anchors.right: seekTrack.right
|
|
anchors.verticalCenter: seekTrack.verticalCenter
|
|
height: 18
|
|
hoverEnabled: true
|
|
cursorShape: root.clamp01(seekArea.mouseX / seekArea.width) > QbzPlayer.npSeekableMax
|
|
? Qt.ForbiddenCursor
|
|
: Qt.PointingHandCursor
|
|
onPressed: QbzPlayer.seek(Math.min(
|
|
root.clamp01(seekArea.mouseX / seekArea.width),
|
|
QbzPlayer.npSeekableMax))
|
|
onPositionChanged: {
|
|
if (seekArea.pressed)
|
|
QbzPlayer.seek(Math.min(
|
|
root.clamp01(seekArea.mouseX / seekArea.width),
|
|
QbzPlayer.npSeekableMax))
|
|
}
|
|
}
|
|
|
|
// Hover time bubble with a downward caret (:103-140).
|
|
Item {
|
|
id: seekTip
|
|
readonly property int atSecs: Math.round(
|
|
root.clamp01(seekArea.mouseX / seekTrack.width) * QbzPlayer.npDurationSecs)
|
|
width: tipLabel.implicitWidth + 18
|
|
height: 24
|
|
x: Math.min(Math.max(seekArea.mouseX - seekTip.width / 2, 0),
|
|
Math.max(0, seekTrack.width - seekTip.width))
|
|
y: -seekTip.height - 11
|
|
opacity: seekArea.containsMouse ? 1.0 : 0.0
|
|
Behavior on opacity {
|
|
NumberAnimation { duration: 80 }
|
|
}
|
|
|
|
// Shadow approximation — one offset rect, the port's
|
|
// stand-in for Slint's drop-shadow-blur (the same
|
|
// choice controls/QbzTooltip.qml documents).
|
|
Rectangle {
|
|
x: 0
|
|
y: 2
|
|
width: seekTip.width
|
|
height: seekTip.height
|
|
radius: 4
|
|
color: "#66000000"
|
|
}
|
|
Rectangle {
|
|
id: tipBubble
|
|
anchors.fill: seekTip
|
|
radius: 4
|
|
color: theme.surfaceElevated
|
|
|
|
Text {
|
|
id: tipLabel
|
|
anchors.centerIn: tipBubble
|
|
text: root.fmt(seekTip.atSecs)
|
|
color: theme.textPrimary
|
|
font.pixelSize: 11
|
|
font.weight: theme.weightMedium
|
|
}
|
|
}
|
|
|
|
// Canvas is this port's polygon primitive
|
|
// (Canvas.Immediate — the other render strategies
|
|
// segfault against list recycling).
|
|
Canvas {
|
|
id: tipCaret
|
|
width: 10
|
|
height: 6
|
|
x: Math.round((seekTip.width - tipCaret.width) / 2)
|
|
y: seekTip.height - 1
|
|
renderTarget: Canvas.Image
|
|
renderStrategy: Canvas.Immediate
|
|
// A Canvas does not repaint on a colour binding.
|
|
property color fill: theme.surfaceElevated
|
|
onFillChanged: tipCaret.requestPaint()
|
|
onPaint: {
|
|
var ctx = tipCaret.getContext("2d")
|
|
if (!ctx)
|
|
return
|
|
ctx.reset()
|
|
ctx.clearRect(0, 0, tipCaret.width, tipCaret.height)
|
|
ctx.fillStyle = tipCaret.fill
|
|
ctx.beginPath()
|
|
ctx.moveTo(0, 0)
|
|
ctx.lineTo(tipCaret.width / 2, tipCaret.height)
|
|
ctx.lineTo(tipCaret.width, 0)
|
|
ctx.closePath()
|
|
ctx.fill()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Transport + quality stamp (:331-357) -------------------
|
|
// The stamp floats at the right of the SAME row instead of owning
|
|
// a line, which is what keeps this block short on a 600px window.
|
|
Rectangle {
|
|
id: transportRow
|
|
width: bottomBlock.width
|
|
height: 46
|
|
radius: theme.radiusSm
|
|
// THE IN-PAGE PLAYER-ZONE RING (:333-338). 3px + an 0.08 tint,
|
|
// lighter than the 0.12 card ring. KioskShell hides both the
|
|
// small bar and its own player ring on this route, so this is
|
|
// the only thing that shows the player zone has the focus.
|
|
border.width: QbzKioskNav.navActive && QbzKioskNav.zone === "player" ? 3 : 0
|
|
border.color: theme.accent
|
|
color: QbzKioskNav.navActive && QbzKioskNav.zone === "player"
|
|
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.08)
|
|
: "transparent"
|
|
|
|
TransportControls {
|
|
id: transport
|
|
anchors.horizontalCenter: transportRow.horizontalCenter
|
|
anchors.verticalCenter: transportRow.verticalCenter
|
|
playCircle: true
|
|
classicActions: true
|
|
favorite: root.npFavorite
|
|
onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) }
|
|
onTrackInfoRequested: root.openTrackInfo()
|
|
}
|
|
|
|
AudioStamp {
|
|
anchors.right: transportRow.right
|
|
anchors.rightMargin: 2
|
|
anchors.verticalCenter: transportRow.verticalCenter
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// File-local components
|
|
// =====================================================================
|
|
|
|
// The tab used by BOTH panels (:78-115): the left Cover/Lyrics pair and
|
|
// the right Up Next/History pair. Label over a 2px underline, the whole
|
|
// thing clickable, an optional 2px focus ring (the "tab" ring family).
|
|
// Note the delta vs KioskSearch's SearchTab: 14px/bold + a 4px gap here,
|
|
// 15px/semibold + no gap there.
|
|
component QueueTab: Rectangle {
|
|
id: tab
|
|
|
|
property string label: ""
|
|
property bool active: false
|
|
// Only the RIGHT panel's pair joins the nav model; the left toggle
|
|
// leaves this false (:82-84).
|
|
property bool navFocused: false
|
|
signal picked()
|
|
|
|
implicitWidth: tabColumn.width + 4
|
|
implicitHeight: tabColumn.height
|
|
color: "transparent"
|
|
radius: theme.radiusSm
|
|
border.width: tab.navFocused ? 2 : 0
|
|
border.color: theme.accent
|
|
|
|
// alignment: start — the label sits at the TOP of the row's height,
|
|
// so the underlines of both panels line up across the divider.
|
|
Column {
|
|
id: tabColumn
|
|
x: 2
|
|
y: 0
|
|
width: tabLabel.implicitWidth
|
|
spacing: 4
|
|
|
|
Text {
|
|
id: tabLabel
|
|
text: tab.label
|
|
color: tab.active ? theme.textPrimary : theme.textMuted
|
|
font.pixelSize: 14
|
|
font.weight: theme.weightBold
|
|
}
|
|
|
|
Rectangle {
|
|
width: tabColumn.width
|
|
height: 2
|
|
radius: 2
|
|
color: tab.active ? theme.accent : "transparent"
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: tab
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: tab.picked()
|
|
}
|
|
}
|
|
|
|
// The queue row (:13-76). NOT KioskTrackRow — 58px instead of 62, a 44px
|
|
// cover instead of 46, and its own type scale.
|
|
component QueueRowLite: Rectangle {
|
|
id: qrow
|
|
|
|
property var item: ({})
|
|
// The RESOLVED local cover path; "" leaves the bare elevated square,
|
|
// which is what the reference's `artwork.width > 0` gate does (:36).
|
|
property string artPath: ""
|
|
property bool navFocused: false
|
|
signal play()
|
|
|
|
readonly property string rowTitle: qrow.item && qrow.item.title ? qrow.item.title : ""
|
|
readonly property string rowArtist: qrow.item && qrow.item.artist ? qrow.item.artist : ""
|
|
readonly property string rowDuration: qrow.item && qrow.item.duration ? qrow.item.duration : ""
|
|
|
|
height: 58
|
|
radius: theme.radiusSm
|
|
// Row ring family: 2px + an 0.18 tint.
|
|
color: qrow.navFocused
|
|
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.18)
|
|
: rowArea.containsMouse
|
|
? theme.alphaTier(6)
|
|
: "transparent"
|
|
border.width: qrow.navFocused ? 2 : 0
|
|
border.color: theme.accent
|
|
|
|
Row {
|
|
id: rowCells
|
|
anchors.left: qrow.left
|
|
anchors.leftMargin: 8
|
|
anchors.right: qrow.right
|
|
anchors.rightMargin: 12
|
|
anchors.top: qrow.top
|
|
anchors.bottom: qrow.bottom
|
|
spacing: 12
|
|
|
|
Item {
|
|
id: artCell
|
|
width: 44
|
|
height: rowCells.height
|
|
|
|
Rectangle {
|
|
id: artTile
|
|
width: 44
|
|
height: 44
|
|
y: (artCell.height - artTile.height) / 2
|
|
radius: 5
|
|
color: theme.surfaceElevated
|
|
clip: true
|
|
|
|
RoundedImage {
|
|
anchors.fill: artTile
|
|
visible: qrow.artPath !== ""
|
|
source: qrow.artPath
|
|
radius: 5
|
|
fit: "crop"
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
id: rowTextCol
|
|
width: Math.max(0, rowCells.width - artCell.width - rowDurationLabel.width
|
|
- 2 * rowCells.spacing)
|
|
height: rowCells.height
|
|
|
|
Column {
|
|
anchors.verticalCenter: rowTextCol.verticalCenter
|
|
width: rowTextCol.width
|
|
spacing: 2
|
|
|
|
Text {
|
|
width: rowTextCol.width
|
|
text: qrow.rowTitle
|
|
// The reference's accent rule (:50). In the reference
|
|
// `playing` is true ONLY for the now-playing row, and
|
|
// this view renders only upcoming and history — so the
|
|
// rule never fires there. The Qt QueueRow (queue_qt.rs
|
|
// :35-66) carries NO `playing` field at all, so the
|
|
// read is undefined and the strict === keeps the arm
|
|
// dead: every row is text-primary, exactly as the
|
|
// reference renders. Do NOT "fix" it into an
|
|
// id === npTrackId comparison: that would light rows
|
|
// the reference leaves plain.
|
|
color: qrow.item && qrow.item.playing === true
|
|
? theme.accent : theme.textPrimary
|
|
font.pixelSize: 14
|
|
font.weight: theme.weightMedium
|
|
elide: Text.ElideRight
|
|
wrapMode: Text.NoWrap
|
|
maximumLineCount: 1
|
|
}
|
|
|
|
Text {
|
|
width: rowTextCol.width
|
|
text: qrow.rowArtist
|
|
color: theme.textMuted
|
|
font.pixelSize: 13
|
|
elide: Text.ElideRight
|
|
wrapMode: Text.NoWrap
|
|
maximumLineCount: 1
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: rowDurationLabel
|
|
height: rowCells.height
|
|
text: qrow.rowDuration
|
|
color: theme.textMuted
|
|
font.pixelSize: 13
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: rowArea
|
|
anchors.fill: qrow
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: qrow.play()
|
|
}
|
|
}
|
|
}
|