Two defect classes the owner found by playing music, both of them the port diverging from a reference that gets it right. QUEUE TRACK FIDELITY. The reference has ONE builder, make_queue_track (playback.rs:2426), and it takes the raw Track, so bit_depth and sample_rate come straight off the API model. This port forked that into ten builders and five of them mapped from a DISPLAY ROW instead — a row that carries qualityTier / qualityDetail / qualityLabel, the already-formatted strings, and no raw numbers. Those five hardcoded bit_depth: None, sample_rate: None: playlist_qt, artist_qt, label_qt, library_qt and playback_qt's feed_queue_track. The consequence reached the user as a broken NPB AudioStamp: the track edge seeds quality_state from those same numbers, so starting playback from a playlist, an artist page, a label page or a Library-feed row left TRACK_MAX at zero and the badge could not render a complete bit-depth / sample-rate line. Starting from an album was perfect, which is what made it look intermittent — the album path was one of the five that had always built from the raw Track. The rows now carry the numbers alongside the strings, because the producer derives the strings FROM those numbers and was discarding them; parsing "24-bit / 96 kHz" back into integers would have been a second source of truth. The same builders were dropping artwork_url, which is why the queue sidebar had no covers for playlist-sourced or local rows. Also fixed here, found while tracing: local_playlist_qt::play shipped context: None, so set_queue_stamped fell through to derive_context, which answers None for a many-album playlist. The song card then used its per-track album fallback and pointed at a local album_group_key instead of the playlist. Pre-existing for the header Play, but a guard added last round routed the row click through the same path, which turned it into a regression there. SHUFFLE. set_shuffle(true) only randomises what comes NEXT, so four entry points raised the mode and then played the list's #1 track: the album header, the playlist header, the local-playlist header and every local play_rows call. The one track the user actually hears on pressing Shuffle was deterministic. All four now mix their own list with the xorshift Fisher-Yates the port already uses in play_track_list_in and start at the top of the mixed order. The mode stays raised so playback continues shuffled past the list. The owner traced the flag-and-play-from-the-top shape to a very early Tauri experiment; it is not the behaviour anywhere. Not addressed, going to a separate pass: a Qobuz playlist carrying local or Plex sidecar rows. The reference routes those through the local snapshot (playback.rs:3488 is_mixed); this port has no sidecar concept on playlist_qt at all, so such a row is still typed as qobuz. Same defect class, one layer up. cc
1317 lines
63 KiB
QML
1317 lines
63 KiB
QML
// Right-side Queue panel — QML port of
|
|
// crates/qbz-ui/ui/shell/QueueSidebar.slint.
|
|
//
|
|
// Tabs (Queue / History), NOW PLAYING card (live heart), UP NEXT with the
|
|
// #442 "Next in queue" / "Next up" section markers and the "stop after this
|
|
// song" marker, 40-row pagination, live search filter, row actions (play /
|
|
// remove / remove-all-after / stop-after / add-to-playlist / track info /
|
|
// heart), drag reorder (6px press-drag, drop indicator + floating ghost),
|
|
// footer (count line + Clear / save-as-playlist / infinite play / sleep
|
|
// timer + search field), History tab (thumbnail rows, click replays as a
|
|
// fresh single-track queue), exact empty and no-results states.
|
|
// Data: QbzQueue.queueJson (queue_qt.rs QueueDoc) + the two scalar sleep
|
|
// properties on the same singleton.
|
|
//
|
|
// EPHEMERAL rows drop every PERSISTENCE affordance (heart, Add to playlist,
|
|
// Track info) exactly as QueueSidebar.slint gates on `item.is-ephemeral`:
|
|
// an ephemeral play must leave nothing behind inside QBZ.
|
|
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Window
|
|
import com.blitzfc.qbz
|
|
import "../controls"
|
|
import "../theme"
|
|
|
|
Rectangle {
|
|
id: root
|
|
color: "transparent"
|
|
clip: true
|
|
|
|
// Queue (0) / History (1) — Slint QueueState.tab.
|
|
property int tab: 0
|
|
readonly property var doc: JSON.parse(QbzQueue.queueJson)
|
|
readonly property var currentRow: doc.current || null
|
|
readonly property var upcoming: doc.upcoming || []
|
|
readonly property var historyRows: doc.history || []
|
|
readonly property bool queueEmpty: currentRow === null && (doc.upcomingTotal || 0) === 0
|
|
// "Stop after this song": the decimal id of the marked track, "" for none.
|
|
readonly property string stopAfterId: doc.stopAfterId || ""
|
|
// A search is active but nothing matched — a different state from an empty
|
|
// queue, and the reason the reference echoes the query back.
|
|
readonly property bool noSearchResults: !queueEmpty
|
|
&& (doc.searchQuery || "") !== "" && (doc.upcomingTotal || 0) === 0
|
|
// The now-playing track is ephemeral -> the queue cannot be persisted, so
|
|
// the footer's save-as-playlist disappears (QueueSidebar.slint:850).
|
|
readonly property bool currentEphemeral: currentRow !== null && currentRow.isEphemeral === true
|
|
|
|
// The panel is `visible`-gated rather than conditionally mounted, so this
|
|
// is the equivalent of the reference's `init => QueueState.panel-opened()`:
|
|
// pull a fresh snapshot each time it comes back on screen, because the
|
|
// queue can change while it is hidden (a session restore, a play from any
|
|
// view). Cheap — an unchanged document never reaches Qt.
|
|
onVisibleChanged: {
|
|
if (!visible)
|
|
return
|
|
QbzQueue.queuePanelOpened()
|
|
// AND re-dispatch the covers: the publish dedup means
|
|
// `queuePanelOpened` above often changes nothing, so it cannot be
|
|
// relied on to trigger the doc change either. (This handler runs
|
|
// OUTSIDE the docChanged storm, which is why it used to be the only
|
|
// dispatch that asked for the right urls — see `dispatchCovers`.)
|
|
dispatchCovers()
|
|
}
|
|
|
|
// --- Favourite state, id-keyed (the ArtistView.localToggles idiom) ---
|
|
// The rows here are plain JS objects parsed out of `doc`, so the old
|
|
// `row.isFavorite = !row.isFavorite` notified nothing and NO glyph in
|
|
// this panel ever moved on click — not the Up Next menu entry, not the
|
|
// Now Playing heart. A map at root level is what fits: the queue
|
|
// document is re-parsed constantly (every position tick, every queue
|
|
// edit), so a per-delegate property alone would be overwritten by the
|
|
// next republish carrying the pre-toggle value.
|
|
//
|
|
// Both writers agree on the same map: the optimistic flip, and
|
|
// `libraryFavoriteChanged` — which carries what the write ACTUALLY
|
|
// produced, so it is also the rollback for a failed one and the way a
|
|
// heart set on another surface reaches this panel.
|
|
property var favOverrides: ({})
|
|
function favState(row) {
|
|
if (!row) return false
|
|
return root.favOverrides[row.id] !== undefined
|
|
? root.favOverrides[row.id]
|
|
: row.isFavorite === true
|
|
}
|
|
function setFavState(id, value) {
|
|
var m = root.favOverrides
|
|
m[id] = value
|
|
// Rebind requires a NEW object reference (same-ref is not a change).
|
|
root.favOverrides = Object.assign({}, m)
|
|
}
|
|
function toggleFav(row) {
|
|
if (!row)
|
|
return
|
|
root.setFavState(row.id, !root.favState(row))
|
|
QbzQueue.queueToggleFavorite("track", row.id)
|
|
}
|
|
Connections {
|
|
target: QbzLibrary
|
|
function onLibraryFavoriteChanged(key, value) {
|
|
if (key.indexOf("track:") === 0)
|
|
root.setFavState(key.substring(6), value)
|
|
}
|
|
}
|
|
|
|
// url-keyed cover map (shared artwork pipeline).
|
|
property var coverMap: ({})
|
|
|
|
// --- UP NEXT drag-reorder state (QueueSidebar.slint:371-410) ---------
|
|
// Panel-local on purpose: this is NOT the QbzShell drag global (that one
|
|
// is the track -> sidebar-playlist ghost, and its drag_end() posts an
|
|
// "add tracks to playlist" — a reorder must never reach it).
|
|
// ALL indices here are PAGE-LOCAL, which is exactly what queue_qt.rs's
|
|
// move_track takes (it resolves page-local -> queue-wide itself, filter
|
|
// and pagination included) — same contract as play/remove/remove-after.
|
|
property bool reorderActive: false
|
|
property int reorderFrom: -1 // page-local index of the dragged row
|
|
property int reorderOver: -1 // insertion slot, 0..N
|
|
property real reorderPointerY: 0 // live pointer WINDOW y (drives the ghost)
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
// Pointer window-Y -> insertion slot [0..N]. The Slint does this with a
|
|
// fixed 44px stride plus a 22px correction per #442 section header; here
|
|
// the rows' real geometry answers it directly (the delegate exposes its
|
|
// row center in window space), so section headers, the list padding and
|
|
// the 8px inter-row spacing are all accounted for without a second
|
|
// arithmetic model to keep in sync. Top half of row k -> slot k, bottom
|
|
// half -> slot k+1, past the last row -> N (append). <=40 rows per page.
|
|
function slotFromPointer(winY) {
|
|
var n = root.upcoming.length
|
|
for (var i = 0; i < n; i++) {
|
|
var d = upcomingRepeater.itemAt(i)
|
|
if (d && winY < d.rowCenterY())
|
|
return i
|
|
}
|
|
return n
|
|
}
|
|
|
|
// Commit — QueueSidebar.slint commit-reorder(). `to == from` and
|
|
// `to == from + 1` both drop back onto the SAME gap, so neither reaches
|
|
// the bridge (move_track's from == to early return would swallow them
|
|
// anyway, at the cost of a queue round-trip and a republish).
|
|
function commitReorder() {
|
|
var from = root.reorderFrom
|
|
var to = root.reorderOver
|
|
root.reorderActive = false
|
|
root.reorderFrom = -1
|
|
root.reorderOver = -1
|
|
if (from < 0 || to < 0 || to === from || to === from + 1)
|
|
return
|
|
QbzQueue.queueMoveTrack(from, to)
|
|
}
|
|
|
|
Connections {
|
|
target: QbzLibrary
|
|
function onLibraryArtworkReady(key, path) {
|
|
var m = root.coverMap
|
|
m[key] = path
|
|
root.coverMap = Object.assign({}, m)
|
|
}
|
|
}
|
|
Component.onCompleted: dispatchCovers()
|
|
onDocChanged: dispatchCovers()
|
|
// Collect the covers of the document CURRENTLY on the property and ask the
|
|
// shared pipeline for them.
|
|
//
|
|
// It re-derives current/upcoming/history from `root.doc` instead of reading
|
|
// the `currentRow` / `upcoming` / `historyRows` bindings, and that is the
|
|
// whole fix: called from `onDocChanged`, those three derived properties are
|
|
// still holding the PREVIOUS document. Measured on this Qt build (three
|
|
// publishes, one per event-loop turn): reading the derived properties in
|
|
// the handler dispatched [], then A's urls on B's publish, then B's on C's
|
|
// — always exactly one publish behind, so the row on screen NEVER had its
|
|
// cover requested. Re-reading `root.doc` in the same handler dispatched
|
|
// A, B, C in step.
|
|
//
|
|
// It is also why the defect looked source-dependent: an ALBUM queue has one
|
|
// cover for every row, so asking one publish late asks for the same url and
|
|
// is indistinguishable from correct. A PLAYLIST queue (a different album per
|
|
// row) and a mixed LOCAL queue ask for the previous row's cover forever.
|
|
function dispatchCovers() {
|
|
var d = root.doc
|
|
var cur = d.current || null
|
|
var up = d.upcoming || []
|
|
var hist = d.history || []
|
|
var urls = []
|
|
if (cur && cur.artUrl) urls.push(cur.artUrl)
|
|
var i
|
|
for (i = 0; i < up.length; i++) if (up[i].artUrl) urls.push(up[i].artUrl)
|
|
for (i = 0; i < hist.length; i++) if (hist[i].artUrl) urls.push(hist[i].artUrl)
|
|
if (urls.length > 0) {
|
|
QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
|
|
root.artPulse = true
|
|
artPulseOff.restart()
|
|
}
|
|
}
|
|
|
|
// --- skeleton pulse (PER-ITEM COVERS ONLY) ---------------------------
|
|
// The queue is LOCAL state — it never "loads", so this panel has no
|
|
// list-level skeleton ON PURPOSE: there is no asynchronous fetch to
|
|
// stand in for. What IS asynchronous is each row's cover, which is why
|
|
// the placeholders here are per-item and nothing else.
|
|
// GATING RULE: freeze on NOT VISIBLE — the panel collapsed, or the
|
|
// window minimized/hidden. NEVER on lost focus (a tiling desktop keeps
|
|
// windows visible and unfocused).
|
|
property bool skelPhase: false
|
|
readonly property bool windowShowing: root.Window.window
|
|
? (root.Window.window.visibility !== Window.Minimized
|
|
&& root.Window.window.visibility !== Window.Hidden)
|
|
: true
|
|
// A cover that never resolves (no art, or a failed fetch) leaves the map
|
|
// untouched, so every placeholder is bounded — see QbzSkeleton settleMs.
|
|
readonly property int artSettleMs: 2500
|
|
property bool artPulse: false
|
|
Timer {
|
|
id: artPulseOff
|
|
interval: root.artSettleMs
|
|
onTriggered: root.artPulse = false
|
|
}
|
|
Timer {
|
|
interval: 900
|
|
repeat: true
|
|
running: root.artPulse && root.visible && root.windowShowing
|
|
onTriggered: root.skelPhase = !root.skelPhase
|
|
}
|
|
function coverPending(url) {
|
|
return (url || "") !== "" && (root.coverMap[url] || "") === ""
|
|
}
|
|
|
|
component QueueTab: Text {
|
|
property bool active: false
|
|
signal clicked()
|
|
|
|
font.pixelSize: theme.fontBody
|
|
font.weight: theme.weightSemibold
|
|
color: active ? theme.textPrimary
|
|
: tabArea.containsMouse ? theme.textSecondary : theme.textMuted
|
|
verticalAlignment: Text.AlignVCenter
|
|
height: parent ? parent.height : 0
|
|
|
|
MouseArea {
|
|
id: tabArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: parent.clicked()
|
|
}
|
|
}
|
|
|
|
// Row action icon button (the panel's small IconButton).
|
|
|
|
|
|
// One UP NEXT / History row (QueueRow.slint).
|
|
component QueueRow: Rectangle {
|
|
id: qrRoot
|
|
property var row: ({})
|
|
property int rowIndex: 0
|
|
property bool showNumber: true
|
|
property bool inQueue: true
|
|
/// Drag-reorder source? UP NEXT rows only — History has no order to
|
|
/// commit, and it keeps its drag-scroll because of this (see qrArea's
|
|
/// preventStealing).
|
|
property bool reorderable: false
|
|
|
|
readonly property bool hovered: qrArea.containsMouse || menuArea.containsMouse
|
|
readonly property bool isActive: inQueue && QbzPlayer.npTrackId !== "" && QbzPlayer.npTrackId === row.id
|
|
readonly property bool dragSource: reorderable && root.reorderActive
|
|
&& root.reorderFrom === rowIndex
|
|
|
|
// Row center in WINDOW space — what slotFromPointer() measures the
|
|
// pointer against. A function, not a binding: mapToItem() is not
|
|
// reactive, and it is only ever read during a live drag.
|
|
function rowCenterY() {
|
|
return qrRoot.mapToItem(null, 0, qrRoot.height / 2).y
|
|
}
|
|
|
|
height: 44
|
|
radius: theme.radiusSm
|
|
color: hovered ? theme.surfaceHover
|
|
: (rowIndex % 2 === 1 ? "#592a2a2a" : "transparent")
|
|
border.width: dragSource ? 1 : 0
|
|
border.color: theme.accent
|
|
opacity: dragSource ? 0.4 : 1.0
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: theme.spacingSm
|
|
anchors.rightMargin: theme.spacingXs
|
|
anchors.topMargin: 4
|
|
anchors.bottomMargin: 4
|
|
spacing: 9
|
|
|
|
// Leading: track number (UP NEXT) or thumbnail (History). On the
|
|
// row carrying the "stop after this song" marker the number is
|
|
// REPLACED by the accent CircleStop — same slot, so the list never
|
|
// reflows when a marker is placed or cleared.
|
|
Item {
|
|
visible: showNumber
|
|
width: 22
|
|
height: parent.height
|
|
readonly property bool marked: qrRoot.inQueue
|
|
&& root.stopAfterId !== "" && row.id === root.stopAfterId
|
|
Text {
|
|
visible: !parent.marked
|
|
anchors.centerIn: parent
|
|
width: parent.width
|
|
text: rowIndex + 1 + (doc.page || 0) * 40
|
|
color: theme.textMuted
|
|
font.pixelSize: 12
|
|
horizontalAlignment: Text.AlignHCenter
|
|
}
|
|
QbzIcon {
|
|
visible: parent.marked
|
|
anchors.centerIn: parent
|
|
name: "circle-stop"
|
|
width: 13
|
|
height: 13
|
|
tintName: "accent"
|
|
}
|
|
}
|
|
Rectangle {
|
|
visible: !showNumber
|
|
width: 34
|
|
height: 34
|
|
radius: 4
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: theme.surfaceElevated
|
|
clip: true
|
|
RoundedImage {
|
|
anchors.fill: parent
|
|
source: root.coverMap[row.artUrl] || ""
|
|
radius: 4
|
|
}
|
|
// Per-item: this thumbnail clears when ITS cover lands, so
|
|
// History fills in progressively rather than all at once.
|
|
QbzSkeleton {
|
|
variant: "art"
|
|
anchors.fill: parent
|
|
blockRadius: 4
|
|
visible: root.coverPending(row.artUrl)
|
|
phase: root.skelPhase
|
|
settleMs: root.artSettleMs
|
|
}
|
|
}
|
|
|
|
// Title + artist.
|
|
Column {
|
|
width: parent.width - (showNumber ? 22 : 34) - durText.width - 32 - 3 * 9
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 2
|
|
Row {
|
|
spacing: 5
|
|
Text {
|
|
text: row.title
|
|
color: theme.textPrimary
|
|
font.pixelSize: 12
|
|
font.weight: theme.weightMedium
|
|
elide: Text.ElideRight
|
|
width: Math.min(implicitWidth, parent.parent.width - (row.explicit ? 21 : 0))
|
|
}
|
|
Rectangle {
|
|
visible: row.explicit
|
|
width: 16
|
|
height: 16
|
|
radius: 3
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: theme.surfaceElevated
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "E"
|
|
color: theme.textMuted
|
|
font.pixelSize: 9
|
|
font.weight: theme.weightSemibold
|
|
}
|
|
}
|
|
}
|
|
Text {
|
|
width: parent.width
|
|
text: row.artist
|
|
color: theme.textMuted
|
|
font.pixelSize: 10
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
Text {
|
|
id: durText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: row.duration
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
}
|
|
// ⋯ menu trigger (UP NEXT rows only; always visible, 32px hit).
|
|
Rectangle {
|
|
visible: inQueue
|
|
width: 32
|
|
height: 32
|
|
radius: theme.radiusSm
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: menuArea.containsMouse ? theme.surfaceElevated : "transparent"
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
name: "ellipsis"
|
|
width: 16
|
|
height: 16
|
|
tintName: menuArea.containsMouse ? "textPrimary" : "muted"
|
|
}
|
|
MouseArea {
|
|
id: menuArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: function (mouse) { rowMenu.openAtCursor(menuArea, mouse.x, mouse.y) }
|
|
}
|
|
QbzContextMenu {
|
|
id: rowMenu
|
|
menuWidth: 196
|
|
// QueueSidebar.slint's track menu, in its order. The
|
|
// last entry is this port's own addition (the reference
|
|
// has no heart in the queue row menu) and is kept
|
|
// BELOW the ported set so the 1:1 block reads intact.
|
|
// EPHEMERAL rows drop the two persistence entries, the
|
|
// same gate the .slint applies (and the reason its menu
|
|
// is 105px tall instead of 175px on those rows).
|
|
Repeater {
|
|
model: {
|
|
var items = [
|
|
{ "label": QbzSession.tr("Remove from queue", QbzSession.trRev), "icon": "trash-2", "action": "remove" },
|
|
{ "label": row.id === root.stopAfterId
|
|
? QbzSession.tr("Cancel stop after this", QbzSession.trRev)
|
|
: QbzSession.tr("Stop after this", QbzSession.trRev), "icon": "circle-stop", "action": "stop-after" },
|
|
{ "label": QbzSession.tr("Remove all after", QbzSession.trRev), "icon": "list-x", "action": "remove-after" },
|
|
]
|
|
if (row.isEphemeral !== true) {
|
|
// LOCAL/Plex rows drop it too: the queue's
|
|
// `add_to_playlist` refuses them
|
|
// (queue_qt.rs — a queue row's id is a
|
|
// library.db / Plex synthetic id and this
|
|
// panel has no source-aware resolver to
|
|
// turn it into a local-mode ref), so the
|
|
// entry would render and no-op. Absent
|
|
// beats dead; the LOCAL surfaces that DO
|
|
// have the resolver offer it instead
|
|
// (local/LocalTrackRow.qml).
|
|
if (row.isLocal !== true)
|
|
items.push({ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-plus", "action": "add-to-playlist" })
|
|
items.push({ "label": QbzSession.tr("Track info", QbzSession.trRev), "icon": "info", "action": "track-info" })
|
|
items.push({ "label": root.favState(row) ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": root.favState(row) ? "heart-filled" : "heart", "action": "favorite" })
|
|
}
|
|
return items
|
|
}
|
|
delegate: Rectangle {
|
|
required property var modelData
|
|
width: parent ? parent.width : 0
|
|
height: 33
|
|
radius: 5
|
|
color: rmiArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 8
|
|
spacing: 8
|
|
QbzIcon { name: modelData.icon; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
|
|
Text {
|
|
height: parent.height
|
|
width: parent.width - 23
|
|
text: modelData.label
|
|
color: theme.textSecondary
|
|
font.pixelSize: 13
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
MouseArea {
|
|
id: rmiArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
rowMenu.close()
|
|
var a = modelData.action
|
|
if (a === "remove") QbzQueue.queueRemoveUpcoming(rowIndex)
|
|
else if (a === "stop-after") QbzQueue.queueToggleStopAfter(row.id)
|
|
else if (a === "remove-after") QbzQueue.queueRemoveAllAfter(rowIndex)
|
|
else if (a === "add-to-playlist") QbzQueue.queueAddToPlaylist(rowIndex)
|
|
else if (a === "track-info") qrRoot.openTrackInfo()
|
|
else if (a === "favorite") root.toggleFav(row)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Row body: click plays, >6px press-drag reorders (QueueSidebar
|
|
// .slint's `ta` does both from one TouchArea — one area per row, not
|
|
// two stacked).
|
|
MouseArea {
|
|
id: qrArea
|
|
anchors.fill: parent
|
|
// BEHIND the row's own controls. Declaration order IS z-order in
|
|
// QML and this fills the row from the last declaration, so at the
|
|
// default z it sat on top of the ⋯ button and swallowed its press
|
|
// — the menu never opened, the row just played. Same fix (and the
|
|
// same reason the .slint declares its TouchArea BEFORE the
|
|
// content) as rows/TrackRow.qml's trArea.
|
|
z: -1
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
// THE reason drag-reorder scrolled instead of reordering: the
|
|
// enclosing Flickable filters its children's mouse events and
|
|
// grabs the press once the pointer moves past
|
|
// QStyleHints.startDragDistance (~10px), after which this area
|
|
// gets `canceled`, never `released`. Keeping the grab here is the
|
|
// Qt equivalent of QueueSidebar.slint's `interactive: false` on
|
|
// the queue Flickable — but scoped to the row and without that
|
|
// side effect: Qt's Flickable also drops WHEEL events when it is
|
|
// non-interactive, so wheel + scrollbar keep working, and only a
|
|
// press that starts ON an UP NEXT row is denied to the flick.
|
|
// (Consequence, same as the Slint: no drag-scroll while
|
|
// reordering — pagination covers long lists.)
|
|
preventStealing: qrRoot.reorderable
|
|
property real downY: 0
|
|
property bool dragging: false
|
|
// A release that ended a drag must NOT also play the track
|
|
// (`clicked` fires after `released`).
|
|
property bool suppressClick: false
|
|
onPressed: function (mouse) {
|
|
qrArea.downY = qrArea.mapToItem(null, mouse.x, mouse.y).y
|
|
qrArea.dragging = false
|
|
}
|
|
onPositionChanged: function (mouse) {
|
|
if (!qrRoot.reorderable || !qrArea.pressed)
|
|
return
|
|
// WINDOW space for both the threshold and the slot, so a
|
|
// wheel-scroll mid-gesture cannot corrupt either.
|
|
var winY = qrArea.mapToItem(null, mouse.x, mouse.y).y
|
|
if (!qrArea.dragging && Math.abs(winY - qrArea.downY) > 6) {
|
|
qrArea.dragging = true
|
|
root.reorderActive = true
|
|
root.reorderFrom = qrRoot.rowIndex
|
|
root.reorderOver = qrRoot.rowIndex
|
|
}
|
|
if (qrArea.dragging) {
|
|
root.reorderPointerY = winY
|
|
root.reorderOver = root.slotFromPointer(winY)
|
|
}
|
|
}
|
|
onReleased: {
|
|
if (!qrArea.dragging)
|
|
return
|
|
qrArea.dragging = false
|
|
qrArea.suppressClick = true
|
|
root.commitReorder()
|
|
}
|
|
// Safety net (the .slint keeps one too): if the grab is lost
|
|
// mid-drag the release never arrives here, and without this the
|
|
// panel would stay stuck in reorderActive with a ghost pinned to
|
|
// the last pointer position.
|
|
onCanceled: {
|
|
if (!qrArea.dragging)
|
|
return
|
|
qrArea.dragging = false
|
|
qrArea.suppressClick = true
|
|
root.commitReorder()
|
|
}
|
|
onClicked: {
|
|
if (qrArea.suppressClick) {
|
|
qrArea.suppressClick = false
|
|
return
|
|
}
|
|
if (inQueue) QbzQueue.queuePlayUpcoming(rowIndex)
|
|
else QbzQueue.queuePlayHistory(rowIndex)
|
|
}
|
|
}
|
|
|
|
// Drop indicator — the .slint draws one 2px accent line at the
|
|
// insertion slot, hidden on the two no-op slots (from, from+1). Here
|
|
// it rides the row that OWNS the slot (its top edge, and the last
|
|
// row's bottom edge for the append slot), which needs no overlay
|
|
// geometry and stays correct across the section headers.
|
|
Rectangle {
|
|
visible: qrRoot.reorderable && root.reorderActive
|
|
&& root.reorderOver === qrRoot.rowIndex
|
|
&& root.reorderOver !== root.reorderFrom
|
|
&& root.reorderOver !== root.reorderFrom + 1
|
|
y: -1
|
|
width: parent.width
|
|
height: 2
|
|
radius: 1
|
|
color: theme.accent
|
|
}
|
|
Rectangle {
|
|
visible: qrRoot.reorderable && root.reorderActive
|
|
&& root.reorderOver === root.upcoming.length
|
|
&& qrRoot.rowIndex === root.upcoming.length - 1
|
|
&& root.reorderFrom !== root.upcoming.length - 1
|
|
y: parent.height - 1
|
|
width: parent.width
|
|
height: 2
|
|
radius: 1
|
|
color: theme.accent
|
|
}
|
|
|
|
// --- Track info (rows/TrackRow.qml's lazy-Loader idiom) -------------
|
|
// A full Popup tree per delegate would cost 40 of them on a full page,
|
|
// so it is activated on demand and torn down on close (Qt.callLater so
|
|
// the Popup is not destroyed inside its own signal).
|
|
Loader {
|
|
id: trackInfoLoader
|
|
active: false
|
|
sourceComponent: TrackInfoModal { }
|
|
}
|
|
Connections {
|
|
target: trackInfoLoader.item
|
|
ignoreUnknownSignals: true
|
|
function onClosed() { Qt.callLater(function () { trackInfoLoader.active = false }) }
|
|
}
|
|
function openTrackInfo() {
|
|
if (!qrRoot.row || !qrRoot.row.id)
|
|
return
|
|
trackInfoLoader.active = true
|
|
trackInfoLoader.item.openFor(qrRoot.row.id)
|
|
}
|
|
}
|
|
|
|
Column {
|
|
anchors.fill: parent
|
|
spacing: 0
|
|
|
|
// --- Header: tabs + close button -------------------------------
|
|
Item {
|
|
width: parent.width
|
|
height: 44
|
|
|
|
Row {
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: theme.spacingMd
|
|
anchors.right: closeBtn.left
|
|
anchors.rightMargin: theme.spacingSm
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
height: 28
|
|
spacing: 14
|
|
|
|
QueueTab {
|
|
text: QbzSession.tr("Queue", QbzSession.trRev)
|
|
active: root.tab === 0
|
|
onClicked: root.tab = 0
|
|
}
|
|
Text {
|
|
text: "|"
|
|
color: theme.textDisabled
|
|
font.pixelSize: theme.fontBody
|
|
height: parent.height
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
QueueTab {
|
|
text: QbzSession.tr("History", QbzSession.trRev)
|
|
active: root.tab === 1
|
|
onClicked: root.tab = 1
|
|
}
|
|
}
|
|
Rectangle {
|
|
id: closeBtn
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: theme.spacingSm
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 28
|
|
height: 28
|
|
radius: theme.radiusSm
|
|
color: closeArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
QbzIcon {
|
|
name: "x"
|
|
width: 15
|
|
height: 15
|
|
anchors.centerIn: parent
|
|
tintName: closeArea.containsMouse ? "textPrimary" : "secondary"
|
|
}
|
|
MouseArea {
|
|
id: closeArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: QbzShell.toggleQueue()
|
|
}
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 1
|
|
color: theme.borderSubtle
|
|
}
|
|
|
|
// --- Body --------------------------------------------------------
|
|
Item {
|
|
width: parent.width
|
|
height: parent.height - 45 - footerBlock.height
|
|
|
|
// ===== Queue tab =====
|
|
Flickable {
|
|
visible: root.tab === 0
|
|
anchors.fill: parent
|
|
clip: true
|
|
contentWidth: width
|
|
contentHeight: queueBody.implicitHeight
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
Column {
|
|
id: queueBody
|
|
width: parent.width
|
|
padding: 10
|
|
spacing: 12
|
|
|
|
// NOW PLAYING section.
|
|
Column {
|
|
visible: root.currentRow !== null
|
|
width: parent.width - 20
|
|
spacing: 10
|
|
Text {
|
|
text: QbzSession.tr("NOW PLAYING", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 0.5
|
|
}
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 44
|
|
radius: theme.radiusSm
|
|
color: theme.surfaceElevated
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 6
|
|
anchors.rightMargin: 6
|
|
anchors.topMargin: 4
|
|
anchors.bottomMargin: 4
|
|
spacing: 9
|
|
Rectangle {
|
|
width: 34
|
|
height: 34
|
|
radius: 4
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: theme.surfaceCard
|
|
clip: true
|
|
RoundedImage {
|
|
anchors.fill: parent
|
|
source: root.currentRow ? (root.coverMap[root.currentRow.artUrl] || "") : ""
|
|
radius: 4
|
|
}
|
|
QbzSkeleton {
|
|
variant: "art"
|
|
anchors.fill: parent
|
|
blockRadius: 4
|
|
visible: root.currentRow
|
|
? root.coverPending(root.currentRow.artUrl) : false
|
|
phase: root.skelPhase
|
|
settleMs: root.artSettleMs
|
|
}
|
|
}
|
|
Column {
|
|
// A Row SKIPS invisible children, so the
|
|
// heart's 28px + its 9px gap come back to
|
|
// the text when it is hidden.
|
|
width: parent.width - 34 - 9
|
|
- (root.currentEphemeral ? 0 : 28 + 9)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 2
|
|
Text {
|
|
width: parent.width
|
|
text: root.currentRow ? root.currentRow.title : ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: 12
|
|
font.weight: theme.weightMedium
|
|
elide: Text.ElideRight
|
|
}
|
|
Text {
|
|
width: parent.width
|
|
text: root.currentRow ? root.currentRow.artist : ""
|
|
color: theme.textMuted
|
|
font.pixelSize: 10
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
// Favorite toggle. HIDDEN on an ephemeral
|
|
// track: there is no DB row to favourite, and
|
|
// an ephemeral play must leave nothing behind
|
|
// (QueueSidebar.slint:563-565).
|
|
Rectangle {
|
|
visible: !root.currentEphemeral
|
|
width: 28
|
|
height: 28
|
|
radius: theme.radiusSm
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: npFavArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
name: root.favState(root.currentRow) ? "heart-filled" : "heart"
|
|
width: 17
|
|
height: 17
|
|
tintName: root.favState(root.currentRow) ? "favorite" : (npFavArea.containsMouse ? "textPrimary" : "muted")
|
|
}
|
|
MouseArea {
|
|
id: npFavArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.toggleFav(root.currentRow)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// UP NEXT section.
|
|
Column {
|
|
visible: (doc.upcomingTotal || 0) > 0
|
|
width: parent.width - 20
|
|
spacing: 8
|
|
Text {
|
|
text: QbzSession.tr("UP NEXT", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 0.5
|
|
}
|
|
|
|
Repeater {
|
|
id: upcomingRepeater
|
|
model: root.upcoming
|
|
delegate: Column {
|
|
id: upDelegate
|
|
required property var modelData
|
|
required property int index
|
|
width: parent ? parent.width : 0
|
|
// Row center in window space — slotFromPointer()
|
|
// reads it off the Repeater's items.
|
|
function rowCenterY() { return qrow.rowCenterY() }
|
|
// #442 section header above the row.
|
|
Row {
|
|
visible: modelData.section !== ""
|
|
height: 22
|
|
spacing: 6
|
|
QbzIcon {
|
|
visible: modelData.section === "next-in-queue"
|
|
name: "list-start"
|
|
width: 13
|
|
height: 13
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
tintName: "accent"
|
|
}
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: modelData.section === "next-in-queue"
|
|
? QbzSession.tr("Next in queue", QbzSession.trRev) : QbzSession.tr("Next up", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 0.5
|
|
}
|
|
}
|
|
QueueRow {
|
|
id: qrow
|
|
width: parent.width
|
|
row: modelData
|
|
rowIndex: index
|
|
showNumber: true
|
|
inQueue: true
|
|
reorderable: true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Paginator (plain text controls, no pills).
|
|
Row {
|
|
visible: (doc.pageCount || 1) > 1
|
|
width: parent.width
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
spacing: theme.spacingMd
|
|
Item { width: (parent.width - paginator.width) / 2; height: 1 }
|
|
Row {
|
|
id: paginator
|
|
spacing: theme.spacingMd
|
|
QbzIconButton { btnSize: 30
|
|
name: "chevron-left"
|
|
iconSize: 15
|
|
btnEnabled: (doc.page || 0) > 0
|
|
onClicked: QbzQueue.queueSetPage((doc.page || 0) - 1)
|
|
}
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzSession.tr("Page {} of {}", QbzSession.trRev).replace("{}", (doc.page || 0) + 1).replace("{}", doc.pageCount || 1)
|
|
color: theme.textMuted
|
|
font.pixelSize: 12
|
|
}
|
|
QbzIconButton { btnSize: 30
|
|
name: "chevron-right"
|
|
iconSize: 15
|
|
btnEnabled: (doc.page || 0) < (doc.pageCount || 1) - 1
|
|
onClicked: QbzQueue.queueSetPage((doc.page || 0) + 1)
|
|
}
|
|
}
|
|
Item { width: (parent.width - paginator.width) / 2; height: 1 }
|
|
}
|
|
}
|
|
|
|
// Empty state — no current track and no upcoming.
|
|
Column {
|
|
visible: root.queueEmpty
|
|
width: parent.width - 20
|
|
topPadding: 48 - 10
|
|
spacing: 6
|
|
Text {
|
|
width: parent.width
|
|
text: QbzSession.tr("Your queue is empty", QbzSession.trRev)
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontBody
|
|
font.weight: theme.weightMedium
|
|
horizontalAlignment: Text.AlignHCenter
|
|
}
|
|
Text {
|
|
width: parent.width
|
|
text: QbzSession.tr("Play an album or track to get started", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 12
|
|
horizontalAlignment: Text.AlignHCenter
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
|
|
// No-results state — a search is active but nothing
|
|
// matched. Distinct from the empty queue above: the queue
|
|
// still has tracks, they are just filtered out, so telling
|
|
// the user to "play an album to get started" would be a lie.
|
|
Text {
|
|
visible: root.noSearchResults
|
|
width: parent.width - 20
|
|
topPadding: 32 - 10
|
|
text: QbzSession.tr("No tracks match your search", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 12
|
|
horizontalAlignment: Text.AlignHCenter
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== History tab =====
|
|
Flickable {
|
|
visible: root.tab === 1
|
|
anchors.fill: parent
|
|
clip: true
|
|
contentWidth: width
|
|
contentHeight: historyBody.implicitHeight
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
Column {
|
|
id: historyBody
|
|
width: parent.width
|
|
padding: 10
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: QbzSession.tr("RECENTLY PLAYED", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 0.5
|
|
}
|
|
Repeater {
|
|
model: root.historyRows
|
|
delegate: QueueRow {
|
|
width: parent.width
|
|
row: modelData
|
|
rowIndex: index
|
|
showNumber: false
|
|
inQueue: false
|
|
}
|
|
}
|
|
Text {
|
|
visible: root.historyRows.length === 0
|
|
width: parent.width
|
|
topPadding: 48 - 10
|
|
text: QbzSession.tr("Nothing played yet", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontBody
|
|
horizontalAlignment: Text.AlignHCenter
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Footer: count + actions + inline search (Queue tab) ---------
|
|
Column {
|
|
id: footerBlock
|
|
visible: root.tab === 0 && !root.queueEmpty
|
|
width: parent.width
|
|
spacing: 0
|
|
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
|
|
Text {
|
|
visible: (doc.upcomingTotal || 0) > 0
|
|
width: parent.width
|
|
leftPadding: theme.spacingMd
|
|
rightPadding: theme.spacingMd
|
|
topPadding: 8
|
|
text: (doc.upcomingTotal === 1
|
|
? QbzSession.tr("Showing {} of {} track.", QbzSession.trRev)
|
|
: QbzSession.tr("Showing {} of {} tracks.", QbzSession.trRev))
|
|
.replace("{}", doc.pageEnd || 0).replace("{}", doc.upcomingTotal || 0)
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
}
|
|
Row {
|
|
id: footerRow
|
|
width: parent.width
|
|
leftPadding: theme.spacingMd
|
|
rightPadding: theme.spacingMd
|
|
topPadding: 10
|
|
bottomPadding: 10
|
|
spacing: theme.spacingXs
|
|
|
|
// The row's usable width — `parent.width` here is the PANEL
|
|
// width, padding included; using it raw is what pushed the
|
|
// search field out of the 300px column.
|
|
readonly property int contentWidth:
|
|
width - leftPadding - rightPadding
|
|
|
|
// Action 1 — Clear queue.
|
|
QbzIconButton { btnSize: 30
|
|
name: "trash-list"
|
|
onClicked: QbzQueue.queueClear()
|
|
}
|
|
// Action 2 — Save the queue as a playlist: seeds the shared
|
|
// Add-to-Playlist picker with the whole queue, whose inline
|
|
// "Create new playlist" row is what names it. HIDDEN while an
|
|
// ephemeral track plays — an ephemeral queue cannot be
|
|
// persisted (QueueSidebar.slint:848-858).
|
|
QbzIconButton { btnSize: 30
|
|
name: "add-to-list"
|
|
visible: !root.currentEphemeral
|
|
onClicked: QbzQueue.queueSaveAsPlaylist()
|
|
}
|
|
// Action 3 — infinite play (accent while armed).
|
|
QbzIconButton { btnSize: 30
|
|
name: "infinity"
|
|
active: doc.infinitePlay === true
|
|
onClicked: QbzQueue.queueToggleInfinitePlay()
|
|
}
|
|
// Action 4 — sleep timer. Accent while armed; the popover
|
|
// opens ABOVE the footer with the preset picker / countdown.
|
|
QbzIconButton {
|
|
id: sleepBtn
|
|
btnSize: 30
|
|
name: "clock"
|
|
active: QbzQueue.sleepActive
|
|
onClicked: sleepMenu.opened ? sleepMenu.close() : sleepMenu.open()
|
|
|
|
Popup {
|
|
id: sleepMenu
|
|
// Anchored to the button's top-left and pushed up by
|
|
// its own height, so it grows upward out of the footer
|
|
// (the .slint hard-codes x:-188 y:-214 for the same
|
|
// effect; here the height is not a magic number).
|
|
x: -188
|
|
y: -height - 6
|
|
width: 220
|
|
padding: 0
|
|
// A click INSIDE must not dismiss it, or a preset
|
|
// could never be picked before Set is pressed — the
|
|
// same reason the .slint sets close-on-click-outside.
|
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
|
|
background: Rectangle {
|
|
radius: theme.radiusMd
|
|
color: theme.surfaceCard
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
}
|
|
// Local until Set is pressed: the preset choice is a
|
|
// draft, and the reference keeps it in UI state too.
|
|
property int selectedIndex: 0
|
|
property string customMinutes: "60"
|
|
|
|
contentItem: Column {
|
|
padding: 12
|
|
spacing: 10
|
|
|
|
// Armed: countdown + Cancel.
|
|
Column {
|
|
visible: QbzQueue.sleepActive
|
|
width: 196
|
|
spacing: 8
|
|
Text {
|
|
text: QbzSession.tr("Stops in", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
}
|
|
Text {
|
|
text: QbzQueue.sleepRemaining
|
|
color: theme.accent
|
|
font.pixelSize: 16
|
|
font.weight: theme.weightSemibold
|
|
}
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 32
|
|
radius: theme.radiusSm
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
color: cancelArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
|
Text {
|
|
anchors.fill: parent
|
|
text: QbzSession.tr("Cancel Timer", QbzSession.trRev)
|
|
color: theme.textSecondary
|
|
font.pixelSize: 12
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
MouseArea {
|
|
id: cancelArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
QbzQueue.sleepTimerCancel()
|
|
sleepMenu.close()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Idle: preset picker (+ custom minutes) + Set.
|
|
Column {
|
|
visible: !QbzQueue.sleepActive
|
|
width: 196
|
|
spacing: 10
|
|
Text {
|
|
text: QbzSession.tr("Stop playback after:", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
}
|
|
Column {
|
|
width: parent.width
|
|
spacing: 2
|
|
Repeater {
|
|
model: [
|
|
QbzSession.tr("30 min", QbzSession.trRev),
|
|
QbzSession.tr("1 hr", QbzSession.trRev),
|
|
QbzSession.tr("2 hr", QbzSession.trRev),
|
|
QbzSession.tr("3 hr", QbzSession.trRev),
|
|
QbzSession.tr("5 hr", QbzSession.trRev),
|
|
QbzSession.tr("Custom…", QbzSession.trRev)
|
|
]
|
|
delegate: Rectangle {
|
|
required property var modelData
|
|
required property int index
|
|
width: parent ? parent.width : 0
|
|
height: 30
|
|
radius: theme.radiusSm
|
|
color: sleepMenu.selectedIndex === index
|
|
? theme.accent
|
|
: (presetArea.containsMouse ? theme.surfaceHover : "transparent")
|
|
Text {
|
|
x: 10
|
|
width: parent.width - 20
|
|
height: parent.height
|
|
text: modelData
|
|
color: sleepMenu.selectedIndex === index
|
|
? theme.accentText : theme.textPrimary
|
|
font.pixelSize: 12
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
MouseArea {
|
|
id: presetArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: sleepMenu.selectedIndex = index
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Custom minutes — revealed by the last preset.
|
|
// A bare TextInput in a styled Rectangle, the
|
|
// AddToMixtapeModal convention (and what the
|
|
// .slint does): QbzLineEdit has no numeric arm.
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 32
|
|
visible: sleepMenu.selectedIndex === 5
|
|
radius: theme.radiusSm
|
|
border.width: 1
|
|
border.color: customInput.activeFocus ? theme.accent : theme.borderSubtle
|
|
color: theme.surfaceElevated
|
|
TextInput {
|
|
id: customInput
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 9
|
|
anchors.rightMargin: 34
|
|
color: theme.textPrimary
|
|
font.pixelSize: 12
|
|
verticalAlignment: Text.AlignVCenter
|
|
clip: true
|
|
selectByMouse: true
|
|
// Digits only, so "Set" can never be
|
|
// handed something `parseInt` reads as
|
|
// NaN (the Rust side clamps to
|
|
// [1, 1440] on top of this).
|
|
validator: IntValidator { bottom: 1; top: 1440 }
|
|
inputMethodHints: Qt.ImhDigitsOnly
|
|
text: sleepMenu.customMinutes
|
|
onTextEdited: sleepMenu.customMinutes = text
|
|
Binding {
|
|
target: customInput
|
|
property: "text"
|
|
value: sleepMenu.customMinutes
|
|
when: !customInput.activeFocus
|
|
}
|
|
}
|
|
Text {
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 9
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzSession.tr("min", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 34
|
|
radius: theme.radiusSm
|
|
color: setArea.containsMouse ? theme.accentHover : theme.accent
|
|
Text {
|
|
anchors.fill: parent
|
|
text: QbzSession.tr("Set Timer", QbzSession.trRev)
|
|
color: theme.accentText
|
|
font.pixelSize: 12
|
|
font.weight: theme.weightMedium
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
MouseArea {
|
|
id: setArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
var mins = [30, 60, 120, 180, 300][sleepMenu.selectedIndex]
|
|
if (mins === undefined)
|
|
mins = parseInt(sleepMenu.customMinutes, 10)
|
|
if (!isNaN(mins) && mins > 0)
|
|
QbzQueue.sleepTimerSet(mins)
|
|
sleepMenu.close()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filler so the collapsed magnifier sits at the right edge;
|
|
// the field opens LEFT across it. One gap per visible control
|
|
// (save-as-playlist disappears on an ephemeral queue).
|
|
Item {
|
|
width: Math.max(0, footerRow.contentWidth
|
|
- (root.currentEphemeral ? 3 : 4) * 30 - 30
|
|
- (root.currentEphemeral ? 4 : 5) * theme.spacingXs)
|
|
height: 1
|
|
}
|
|
// Inline queue search (wired) — the shared expandable control.
|
|
QbzLineEdit {
|
|
searchMode: true
|
|
expandable: true
|
|
sm: true
|
|
// Bound to the PANEL, never wider: it opens leftward over
|
|
// the four action buttons, exactly like the Slint
|
|
// ExpandableSearch does over its neighbours.
|
|
openWidth: Math.max(90, footerRow.contentWidth)
|
|
placeholder: QbzSession.tr("Search queue", QbzSession.trRev)
|
|
onEdited: function (v) { QbzQueue.queueSetSearch(v) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Floating drag ghost (QueueSidebar.slint:1098-1130) — a pill following
|
|
// the pointer with the dragged track's title, so it is clear something is
|
|
// being moved. A free child of the panel ROOT (not of the Column, which
|
|
// would stack it), declared LAST so it renders above the list, and with
|
|
// no mouse target of its own. The live pointer window-Y is converted into
|
|
// panel-local space here; mapToItem() is re-evaluated on every
|
|
// reorderPointerY change, which is the only thing that moves it.
|
|
Rectangle {
|
|
visible: root.reorderActive && root.reorderFrom >= 0
|
|
&& root.reorderFrom < root.upcoming.length
|
|
x: theme.spacingMd
|
|
y: root.reorderPointerY - root.mapToItem(null, 0, 0).y - height / 2
|
|
width: root.width - 2 * theme.spacingMd
|
|
height: 34
|
|
radius: theme.radiusSm
|
|
color: theme.surfaceElevated
|
|
border.width: 1
|
|
border.color: theme.accent
|
|
opacity: 0.95
|
|
Text {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: theme.spacingSm
|
|
anchors.rightMargin: theme.spacingSm
|
|
verticalAlignment: Text.AlignVCenter
|
|
text: parent.visible ? (root.upcoming[root.reorderFrom].title || "") : ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: 12
|
|
font.weight: theme.weightMedium
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|