Buttons that rest on surfaceElevated used surfaceHover for their hover fill: a 6% white wash meant for rows that rest transparent. Over an elevated button it lands DARKER than the rest fill — on a modal's dark ground the Cancel/Save/footer buttons went near-black under the pointer. QbzTheme gains surfaceElevatedHover (a lift of surfaceElevated, derived so every palette has it), its ambient half-alpha twin, and the elevatedFill / elevatedHoverFill pair an ambient-aware control paints. Every site with the old pairing (65 across modals, toolbars, selects, circle actions, chips and cards) now hovers to the lift. cc
1079 lines
55 KiB
QML
1079 lines
55 KiB
QML
// Playlist detail view — the QML port of playlist/PlaylistView.slint
|
|
// (phase 17). ONE JSON document (QbzBridge.playlistJson, playlist_qt.rs
|
|
// PlaylistDoc: header + track rows + ownership/follow/pin/sort/search).
|
|
//
|
|
// Header: 150px cover (the playlist's OWN Qobuz graphic contain-fitted,
|
|
// else the member-cover mosaic via the shared cards/PlaylistCollage.qml,
|
|
// which also supplies the placeholder glyph),
|
|
// "PLAYLIST" eyebrow, name, description-short + Read
|
|
// more (the shared AppShell text modal), owner • N tracks • duration,
|
|
// the action row: Play / Shuffle / heart / follow (foreign, subscribe API) /
|
|
// copy (foreign, create+add) / multi-select / context menu. The context menu
|
|
// owns whole-playlist queueing plus pin, share, edit and offline download;
|
|
// edit opens the SHARED controls/PlaylistEditModal.qml through QbzPlaylistEdit
|
|
// (this view mounts no editor of its own; contract D20). Right edge:
|
|
// in-playlist search + the
|
|
// sort dropdown (Default/Title/Artist/Album/Duration/Date added/Custom).
|
|
//
|
|
// Track list: the exact PlaylistView row (# / 36px art / title+artist /
|
|
// Album 220px link / Duration 70 / Quality 92 / heart / cloud reserve /
|
|
// ⋯ menu with Remove-from-playlist for owners), column header, empty and
|
|
// loading states. Per-row ⋯: Play / Play next / Play later / Add to
|
|
// queue / Add to mixtape / Add to playlist / Go to artist / Go to album /
|
|
// Add|Remove from Library / Remove from playlist (owner). "Add to playlist"
|
|
// routes by the ROW's source: a Qobuz row takes the catalog arm, a local /
|
|
// Plex row of an adopted LOCAL playlist takes the local-mode one
|
|
// (QbzPlaylistPicker.openForLocalRow -> local_picker_ref_for_row).
|
|
//
|
|
// Drag & drop (issue #589): the row BODY is the drag source (press-drag
|
|
// >6px — the same gesture that drops onto sidebar playlists). A release
|
|
// INSIDE this list reorders (owner, switches the sort to Custom and
|
|
// persists the order); a release on a sidebar playlist row is the shared
|
|
// add-to-playlist drop (main.rs drag_end). The 2px accent line marks the
|
|
// insertion slot while dragging.
|
|
//
|
|
// LOADING: header + track-row QbzSkeleton placeholders in the shape of what
|
|
// is coming (the Slint mounts a bare LoadingSpinner), plus a per-item cover
|
|
// placeholder that clears when the playlist's own cover lands.
|
|
//
|
|
// Known remaining parity delta (playlist_qt.rs has the full list): Suggested
|
|
// Songs are not ported. Whole-playlist offline download is available both in
|
|
// the header context menu and on the table-header cloud glyph; both share the
|
|
// album preflight.
|
|
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Window
|
|
import com.blitzfc.qbz
|
|
import "../cards"
|
|
import "../controls"
|
|
import "../rows"
|
|
import "../theme"
|
|
|
|
Rectangle {
|
|
id: root
|
|
color: ambientOn ? "transparent" : theme.surfaceMain
|
|
readonly property bool ambientOn: theme.ambientOn
|
|
radius: 12
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
readonly property var doc: parseDoc()
|
|
function parseDoc() {
|
|
try {
|
|
return JSON.parse(QbzBridge.playlistJson)
|
|
} catch (e) {
|
|
return ({})
|
|
}
|
|
}
|
|
readonly property var allTracks: doc.tracks || []
|
|
readonly property bool isOwner: doc.isOwner === true
|
|
readonly property bool loading: doc.loading === true
|
|
|
|
// --- Multi-select (the Qobuz track rows) ------------------------------
|
|
// Same shape as AlbumView: the selection lives in QML, select-all/clear
|
|
// never reach Rust, everything else goes down as a JSON id array through
|
|
// QbzPlayer.bulkTracksAction (bulk_tracks_qt.rs).
|
|
property bool multiSelect: false
|
|
property var selected: ({})
|
|
readonly property int selectedCount: Object.keys(root.selected).length
|
|
readonly property bool multiSelectOn: root.multiSelect
|
|
property string loadedPlaylistId: ""
|
|
function setMultiSelect(on) {
|
|
root.multiSelect = on
|
|
if (!on) { root.selected = ({}); sel.anchorId = "" }
|
|
}
|
|
/// Excel-style selection lives in ONE place — controls/SelectionModel.qml
|
|
/// holds the anchor and the Shift-range rule; this view keeps owning its
|
|
/// map. `mods` is the mouse event's modifiers, forwarded by the row; a
|
|
/// caller with no event (the checkbox, a keyboard path) may omit it.
|
|
SelectionModel { id: sel }
|
|
function toggleSelected(id, mods) {
|
|
root.selected = sel.next(root.selected, id, root.tracks,
|
|
mods === undefined ? Qt.NoModifier : mods)
|
|
}
|
|
function selectedIdsInOrder() {
|
|
var rows = root.tracks
|
|
var out = []
|
|
for (var i = 0; i < rows.length; i++)
|
|
if (root.selected[rows[i].id] === true) out.push(rows[i].id)
|
|
return out
|
|
}
|
|
function bulkAction(action) {
|
|
if (action === "select-all") {
|
|
var m = {}
|
|
var rows = root.tracks
|
|
for (var i = 0; i < rows.length; i++) m[rows[i].id] = true
|
|
root.selected = m
|
|
return
|
|
}
|
|
if (action === "clear") { root.selected = ({}); sel.anchorId = ""; return }
|
|
var ids = root.selectedIdsInOrder()
|
|
if (ids.length === 0) return
|
|
QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "playlist", String(doc.id || ""))
|
|
if (action !== "add-to-playlist" && action !== "add-to-mixtape")
|
|
root.selected = ({})
|
|
}
|
|
// Ctrl+A / Escape hotkey router seam (AppShell duck-types these).
|
|
function selectAll() {
|
|
if (!root.multiSelect) root.setMultiSelect(true)
|
|
root.bulkAction("select-all")
|
|
}
|
|
function exitMultiSelectMode() {
|
|
if (root.multiSelect) root.setMultiSelect(false)
|
|
}
|
|
function syncPlaylistState() {
|
|
var id = doc.id || ""
|
|
if (id === loadedPlaylistId)
|
|
return
|
|
loadedPlaylistId = id
|
|
setMultiSelect(false)
|
|
}
|
|
onDocChanged: syncPlaylistState()
|
|
readonly property string sortField: doc.sortField || "default"
|
|
readonly property bool sortAsc: doc.sortAsc === true
|
|
readonly property string searchQuery: doc.search || ""
|
|
/// A LOCAL detail (`local:<uuid>`) adopted into this view
|
|
/// (local_playlist_qt.rs -> playlist_qt::adopt_doc). It drops the Qobuz-
|
|
/// only affordances and, for the reorder below, it changes WHICH sort is
|
|
/// the reorderable one.
|
|
readonly property bool isLocal: doc.isLocalPlaylist === true
|
|
// A MIXED playlist: a QOBUZ playlist carrying local-file and/or Plex rows
|
|
// from the `library.db` sidecar tables. It is NOT `isLocal` and must not be
|
|
// conflated with it — it keeps every Qobuz affordance (owner, follow, copy,
|
|
// share, the heart). The one thing it loses is reorder; see `canReorder`.
|
|
readonly property bool isMixed: doc.isMixed === true
|
|
/// A Qobuz WRITE can be attempted. `QbzSession.offlineMode` is the shared
|
|
/// tri-state (0 = online, 1 = no connection, 2 = induced offline — the same
|
|
/// property `shell/HeaderBar.qml:101` and `controls/QbzOfflinePlaceholder`
|
|
/// read), and BOTH non-zero arms refuse the request in Rust, so the buttons
|
|
/// that depend on one must not advertise otherwise.
|
|
readonly property bool online: QbzSession.offlineMode === 0
|
|
|
|
// --- Reorderable? (owner findings 5 + 8) -----------------------------
|
|
// PlaylistView.slint:1094-1096 states the rule: the reorder affordance is
|
|
// offered under the CUSTOM sort — "or, on a LOCAL playlist, the Default
|
|
// sort: its natural order IS the editable repo order (B2), written via
|
|
// repo::reorder". A reorder against a COMPUTED order (title, duration, …)
|
|
// is meaningless, which is why it is a sort test and not an ownership one
|
|
// alone.
|
|
//
|
|
// The empty-search term is this port's own, and it is load-bearing: the
|
|
// in-playlist search filters `root.tracks` HERE, in QML, while the Rust
|
|
// arms index into the unfiltered document. Offering a reorder while a
|
|
// filter is active would hand a filtered index to a document index — the
|
|
// Playlist Manager takes the same precaution (`playlist_manager_qt.rs:236`
|
|
// requires `query.is_empty()` before it lights its arrows).
|
|
//
|
|
// MIXED playlists are excluded outright, and this is a data-integrity gate
|
|
// rather than a UX one: a Qobuz playlist's custom order is stored keyed by
|
|
// `u64` catalog id, while a local row's display id is a `library.db` rowid
|
|
// in the same numeric space, so a reorder here would write an order that
|
|
// collides with a real track and moves the WRONG row on the next load.
|
|
// Rust refuses it too (main.rs `playlist_reorder` / `playlist_move_row`) —
|
|
// this only keeps the app from offering something it will not do.
|
|
readonly property bool canReorder: root.isOwner
|
|
&& root.searchQuery.trim() === ""
|
|
&& !root.isMixed
|
|
&& (root.isLocal ? root.sortField === "default" : root.sortField === "custom")
|
|
|
|
// --- Settled state from Rust (`libraryFavoriteChanged` / `pinChanged`) -
|
|
// Two jobs, and the first one is why this exists at all.
|
|
//
|
|
// TRACK ROWS. The list is a ListView with cacheBuffer 500 — it RECYCLES.
|
|
// A row's heart lives on the delegate (rows/TrackRow.qml), so a delegate
|
|
// that is destroyed and rebuilt reads its state back out of `modelData`,
|
|
// i.e. out of `allTracks`. Without patching that array, favouriting a
|
|
// track and scrolling past it and back showed the OLD glyph again — and
|
|
// `playlist_qt` is one of the page-lifetime documents that main.rs's
|
|
// `emit_library_favorite` deliberately does NOT fan out to, so nothing
|
|
// else was going to fix it either.
|
|
//
|
|
// The row objects are patched IN PLACE. Re-deriving `tracks` (or
|
|
// re-parsing `doc`) would push a NEW array into `model:`, and
|
|
// QQuickItemView::setModel() resets the scroll offset to 0 — the user
|
|
// would be thrown to the top of the playlist on every heart click. The
|
|
// live delegates need no array change anyway: each one settles itself
|
|
// from this same signal.
|
|
//
|
|
// BOUNDARY, stated so nobody hunts it twice: this patch lasts until the
|
|
// NEXT republish of `QbzBridge.playlistJson`, because that hands over a
|
|
// freshly parsed object graph. `playlist_qt` keeps its document in a Rust
|
|
// `PAGE` cache and re-serializes it as-is (covers landing, a sort change,
|
|
// an in-playlist search), so a republish restores the heart the rows were
|
|
// STAMPED with. Closing that needs `playlist_qt::apply_favorite_change`
|
|
// on the Rust side — main.rs's `emit_library_favorite` doc lists it as a
|
|
// known gap — not more QML.
|
|
//
|
|
// HEADER. Two one-slot overrides so the heart and the pin also answer a
|
|
// flip made somewhere else (a sidebar card, the Home Pinned rail) instead
|
|
// of waiting for a document republish that those paths do not trigger.
|
|
// They carry their OWN playlist id (one slot each, never a shared one —
|
|
// a shared id would make a pin on playlist B resurrect playlist A's stale
|
|
// heart), so an override expires the moment the page shows anything else.
|
|
property string favOverrideId: ""
|
|
property bool favOverrideValue: false
|
|
property string pinOverrideId: ""
|
|
property bool pinOverrideValue: false
|
|
readonly property bool headerFavorite: (root.favOverrideId !== ""
|
|
&& root.favOverrideId === (doc.id || ""))
|
|
? root.favOverrideValue : (doc.isFavorite === true)
|
|
readonly property bool headerPinned: (root.pinOverrideId !== ""
|
|
&& root.pinOverrideId === (doc.id || ""))
|
|
? root.pinOverrideValue : (doc.pinned === true)
|
|
Connections {
|
|
target: QbzLibrary
|
|
function onLibraryFavoriteChanged(key, value) {
|
|
if (key.indexOf("track:") === 0) {
|
|
var tid = key.substring(6)
|
|
var t = root.allTracks
|
|
for (var i = 0; i < t.length; i++) {
|
|
if (String(t[i].id) === tid) { t[i].isFavorite = value; break }
|
|
}
|
|
return
|
|
}
|
|
var pid = root.doc.id || ""
|
|
if (pid !== "" && key === "playlist:" + pid) {
|
|
root.favOverrideId = pid
|
|
root.favOverrideValue = value
|
|
}
|
|
}
|
|
function onPinChanged(key, value) {
|
|
var pid = root.doc.id || ""
|
|
if (pid !== "" && key === "playlist:" + pid) {
|
|
root.pinOverrideId = pid
|
|
root.pinOverrideValue = value
|
|
}
|
|
}
|
|
}
|
|
|
|
// Visible rows after the in-playlist search filter.
|
|
readonly property var tracks: {
|
|
if (searchQuery.trim() === "") return allTracks
|
|
const needle = searchQuery.trim().toLowerCase()
|
|
return allTracks.filter(function (t) {
|
|
return (t.title || "").toLowerCase().indexOf(needle) >= 0
|
|
|| (t.artist || "").toLowerCase().indexOf(needle) >= 0
|
|
|| (t.album || "").toLowerCase().indexOf(needle) >= 0
|
|
})
|
|
}
|
|
|
|
// --- skeleton pulse (QbzSkeleton's preferred drive mode) -------------
|
|
// ONE 900ms Timer drives EVERY placeholder in this view. GATING RULE:
|
|
// freeze on NOT VISIBLE — the view hidden, or the window minimized /
|
|
// hidden. NEVER on lost focus (a tiling desktop keeps windows visible
|
|
// and unfocused). Same rule as HomeView / LibraryView / AmbientField.
|
|
property bool skelPhase: false
|
|
readonly property bool windowShowing: root.Window.window
|
|
? (root.Window.window.visibility !== Window.Minimized
|
|
&& root.Window.window.visibility !== Window.Hidden)
|
|
: true
|
|
// True when this playlist carries artwork of its OWN (`image_rectangle`
|
|
// — editorial playlists). playlist_qt.rs publishes coverUrl/coverPath for
|
|
// that graphic ONLY; everything else falls back to the member-cover
|
|
// mosaic in `doc.covers`.
|
|
readonly property bool hasOwnArt: (doc.coverUrl || "") !== ""
|
|
// The cover is its OWN progressive item: it clears the moment the
|
|
// playlist's own cover lands, independently of the track rows.
|
|
readonly property bool coverPending: root.hasOwnArt
|
|
&& (doc.coverPath || "") === ""
|
|
// Keep the popup model declarative. The old builder lived on the header
|
|
// Row but the popup called it through `root`, so the right-click reached
|
|
// an undefined JS method and opened a correctly positioned EMPTY panel.
|
|
readonly property var playlistCoverMenuModel: doc.hasCustomCover === true
|
|
? [
|
|
{ "label": QbzSession.tr("Change cover", QbzSession.trRev),
|
|
"icon": "image-plus", "action": "add" },
|
|
{ "label": QbzSession.tr("Remove cover", QbzSession.trRev),
|
|
"icon": "trash-2", "action": "remove" }
|
|
]
|
|
: [
|
|
{ "label": QbzSession.tr("Add cover", QbzSession.trRev),
|
|
"icon": "image-plus", "action": "add" }
|
|
]
|
|
// The header has no data at all until the first document lands
|
|
// (playlist_qt.rs publishes `{ loading: true }` with empty fields).
|
|
readonly property bool headerPending: root.loading && (doc.name || "") === ""
|
|
readonly property bool listPending: root.loading && root.allTracks.length === 0
|
|
Timer {
|
|
interval: 900
|
|
repeat: true
|
|
running: (root.headerPending || root.listPending || root.coverPending)
|
|
&& root.visible && root.windowShowing
|
|
onTriggered: root.skelPhase = !root.skelPhase
|
|
}
|
|
|
|
function sortLabel() {
|
|
return sortField === "title" ? QbzSession.tr("Title", QbzSession.trRev)
|
|
: sortField === "artist" ? QbzSession.tr("Artist", QbzSession.trRev)
|
|
: sortField === "album" ? QbzSession.tr("Album", QbzSession.trRev)
|
|
: sortField === "duration" ? QbzSession.tr("Duration", QbzSession.trRev)
|
|
: sortField === "added" ? QbzSession.tr("Date added", QbzSession.trRev)
|
|
: sortField === "custom" ? QbzSession.tr("Custom", QbzSession.trRev)
|
|
: QbzSession.tr("Default", QbzSession.trRev)
|
|
}
|
|
|
|
function headerMenuModel() {
|
|
var t = QbzSession.tr
|
|
var r = QbzSession.trRev
|
|
var playable = root.allTracks.length > 0
|
|
var m = [
|
|
{ "label": t("Play", r), "icon": "play-fill", "action": "play",
|
|
"enabled": playable },
|
|
{ "label": t("Play next", r), "icon": "list-start", "action": "next",
|
|
"enabled": playable },
|
|
{ "label": t("Play later", r), "icon": "list-plus", "action": "later",
|
|
"enabled": playable },
|
|
{ "label": t("Add to queue", r), "icon": "list-end", "action": "queue",
|
|
"enabled": playable },
|
|
{ "label": t("Add to mixtape", r), "icon": "cassette-tape", "action": "mixtape" },
|
|
{ "sep": true },
|
|
{ "label": root.headerPinned ? t("Unpin", r) : t("Pin", r),
|
|
"icon": root.headerPinned ? "pin-filled" : "pin", "action": "pin" },
|
|
]
|
|
if (!root.isLocal)
|
|
m.push({ "label": t("Share", r), "icon": "link", "action": "share" })
|
|
if (root.isOwner)
|
|
m.push({ "label": t("Edit playlist", r), "icon": "pen-line", "action": "edit" })
|
|
if (!root.isLocal) {
|
|
var preflighting = QbzOffline.collectionPreflightLoading
|
|
&& QbzOffline.collectionPreflightKey === "playlist:" + String(root.doc.id || "")
|
|
m.push({ "sep": true })
|
|
m.push({ "label": t("Make available offline", r), "icon": "cloud-download",
|
|
"action": "offline",
|
|
"enabled": root.online && playable && !preflighting })
|
|
}
|
|
return m
|
|
}
|
|
|
|
function headerMenuAction(action) {
|
|
if (action === "play") QbzBridge.playlistPlayAll()
|
|
else if (action === "next" || action === "later" || action === "queue")
|
|
QbzBridge.playlistEnqueueAll(action)
|
|
else if (action === "pin") QbzBridge.playlistTogglePin()
|
|
else if (action === "share")
|
|
QbzBridge.playlistShare(String(root.doc.id || ""))
|
|
else if (action === "edit")
|
|
QbzPlaylistEdit.open(String(root.doc.id || ""))
|
|
else if (action === "offline")
|
|
QbzOffline.cachePlaylist(String(root.doc.id || ""))
|
|
else if (action === "mixtape")
|
|
// Same payload shape the cards use (QbzMyQbzAdd.open). Source-aware:
|
|
// a local playlist carries "local", a Qobuz one "qobuz".
|
|
QbzMyQbzAdd.open(JSON.stringify([{
|
|
"itemType": "playlist",
|
|
"source": root.isLocal ? "local" : "qobuz",
|
|
"sourceItemId": String(root.doc.id || ""),
|
|
"title": root.doc.name || ""
|
|
}]))
|
|
}
|
|
|
|
CardMenu {
|
|
id: playlistHeaderMenu
|
|
menuWidth: 224
|
|
entries: root.headerMenuModel()
|
|
onPicked: function (action) { root.headerMenuAction(action) }
|
|
}
|
|
|
|
// --- Reorder state (the view-level half of the shared drag) ----------
|
|
property int reorderFrom: -1
|
|
property int reorderOver: -1
|
|
property string reorderDropPlaylist: ""
|
|
|
|
// The guarded mirror (PlaylistView.slint: Rust clears over-playlist-id
|
|
// in the same turn it clears active, so the value must be captured
|
|
// WHILE the drag is live).
|
|
Connections {
|
|
target: QbzShell
|
|
function onDragOverPlaylistIdChanged() {
|
|
if (QbzShell.dragActive) root.reorderDropPlaylist = QbzShell.dragOverPlaylistId
|
|
}
|
|
function onDragXChanged() { root.updateSlot() }
|
|
function onDragYChanged() { root.updateSlot() }
|
|
function onDragActiveChanged() {
|
|
if (QbzShell.dragActive) return
|
|
// Drag ended (main.rs drag_end already ran — it handled a
|
|
// sidebar-playlist drop, if any). A release INSIDE the list and
|
|
// NOT on a sidebar playlist reorders.
|
|
if (root.reorderFrom >= 0
|
|
&& root.reorderDropPlaylist === ""
|
|
&& root.pointerInList()
|
|
&& root.slotFromPointer() !== root.reorderFrom
|
|
&& root.slotFromPointer() !== root.reorderFrom + 1) {
|
|
QbzBridge.playlistReorder(root.reorderFrom, root.slotFromPointer())
|
|
}
|
|
root.reorderFrom = -1
|
|
root.reorderOver = -1
|
|
root.reorderDropPlaylist = ""
|
|
}
|
|
}
|
|
function pointerInList() {
|
|
if (!trackList) return false
|
|
const tl = trackList.mapToItem(null, 0, 0)
|
|
const br = trackList.mapToItem(null, trackList.width, trackList.height)
|
|
return QbzShell.dragX >= tl.x && QbzShell.dragX <= br.x
|
|
&& QbzShell.dragY >= tl.y && QbzShell.dragY <= br.y
|
|
}
|
|
function slotFromPointer() {
|
|
const tl = trackList.mapToItem(null, 0, 0)
|
|
return Math.max(0, Math.min(tracks.length,
|
|
Math.round((QbzShell.dragY - tl.y + trackList.contentY) / 50)))
|
|
}
|
|
function updateSlot() {
|
|
if (root.reorderFrom >= 0 && QbzShell.dragActive) {
|
|
root.reorderOver = root.pointerInList() ? root.slotFromPointer() : -1
|
|
}
|
|
}
|
|
|
|
// Read more → the shared AppShell text modal (phase 16 pattern).
|
|
function openDescription() {
|
|
var shell = root.parent
|
|
while (shell && shell.openTextModal === undefined) shell = shell.parent
|
|
if (shell) shell.openTextModal(doc.name || "", doc.description || "")
|
|
}
|
|
|
|
// --- Circular header action (CircleAction, on-surface variant) -------
|
|
|
|
|
|
// ============================ the view ================================
|
|
Column {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 32
|
|
anchors.rightMargin: 16
|
|
anchors.topMargin: 11
|
|
anchors.bottomMargin: 16
|
|
spacing: 0
|
|
|
|
Item { width: 1; height: 22 }
|
|
|
|
// Header placeholder — the SHAPE of the header that is coming
|
|
// (150px cover + eyebrow/title/description/meta bars + the round
|
|
// action buttons), not a spinner. Replaced wholesale by the real
|
|
// header the moment the document lands.
|
|
QbzSkeleton {
|
|
visible: root.headerPending
|
|
variant: "header"
|
|
width: parent.width
|
|
height: 150
|
|
coverSize: 150
|
|
coverGap: 24
|
|
actionCount: 6
|
|
phase: root.skelPhase
|
|
}
|
|
|
|
// --- Header ---------------------------------------------------------
|
|
Row {
|
|
visible: !root.headerPending
|
|
width: parent.width
|
|
spacing: 24
|
|
|
|
// Cover — the SAME two arms as PlaylistCard.qml (the card fix of
|
|
// this session, applied to the detail header):
|
|
// 1. the playlist's OWN Qobuz graphic (`image_rectangle`,
|
|
// published as coverUrl/coverPath) — CONTAIN, because those
|
|
// are landscape and cropping cuts the wordmark;
|
|
// 2. otherwise the member-cover MOSAIC through the shared
|
|
// cards/PlaylistCollage.qml (no second collage), which also
|
|
// owns the list-music empty state.
|
|
// `doc.covers` are MEMBER-ALBUM covers, never the playlist's own
|
|
// artwork — binding one of them as the header image is the bug
|
|
// this replaces.
|
|
Rectangle {
|
|
width: 150
|
|
height: 150
|
|
radius: theme.radiusMd
|
|
color: theme.surfaceElevated
|
|
clip: true
|
|
// Arm 2 first: the collage is the BACKDROP. Declaration order
|
|
// is z-order, so the resolved single cover and the skeleton
|
|
// below must come after it.
|
|
PlaylistCollage {
|
|
anchors.fill: parent
|
|
visible: (doc.coverPath || "") === ""
|
|
// A playlist that HAS its own graphic passes no urls, so
|
|
// it shows the placeholder while that graphic downloads
|
|
// instead of flashing a member mosaic (PlaylistCard rule).
|
|
urls: root.hasOwnArt ? [] : (doc.covers || [])
|
|
radius: theme.radiusMd
|
|
}
|
|
// `pad`, not `contain`: the playlist's own graphic is the
|
|
// 800x380 `image_rectangle`, so a 150px square leaves two
|
|
// ~35px bands. Flat theme grey there made the header read as
|
|
// a small picture floating in a box; the bands now carry a
|
|
// gradient sampled from the image's own top and bottom edges
|
|
// (theme/RoundedImage.qml). Slint crops this same header
|
|
// (`playlist/PlaylistView.slint:481` is `image-fit: cover`)
|
|
// and loses half the banner — this is one of the few places
|
|
// the reference is not the target.
|
|
RoundedImage {
|
|
visible: (doc.coverPath || "") !== ""
|
|
anchors.fill: parent
|
|
source: doc.coverPath || ""
|
|
radius: theme.radiusMd
|
|
fit: root.hasOwnArt ? "pad" : "auto"
|
|
}
|
|
// Per-item: the cover shimmers until THIS playlist's cover
|
|
// lands, then clears on its own. settleMs bounds it — a
|
|
// failed download republishes the document with an empty
|
|
// coverPath and would otherwise shimmer forever; on settle
|
|
// the placeholder fades and the collage below takes over
|
|
// (empty urls -> its list-music glyph, the old empty state).
|
|
QbzSkeleton {
|
|
id: plCoverSkel
|
|
visible: root.coverPending
|
|
variant: "art"
|
|
anchors.fill: parent
|
|
blockRadius: theme.radiusMd
|
|
phase: root.skelPhase
|
|
settleMs: 4000
|
|
}
|
|
// Left-click: the lightbox (the shared CoverLightbox, same as
|
|
// the album page). Right-click: the custom-cover menu (Qt
|
|
// port addition — the Slint playlist header has no menu).
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: function (mouse) {
|
|
if (mouse.button === Qt.RightButton) {
|
|
plCoverMenu.openAtCursor(plCoverAnchor, mouse.x, mouse.y)
|
|
} else {
|
|
var src = (doc.coverUrl || "") !== "" ? doc.coverUrl
|
|
: ((doc.covers || []).length > 0 ? doc.covers[0] : "")
|
|
if (src !== "") plCoverLightbox.openWith(src)
|
|
}
|
|
}
|
|
}
|
|
Item { id: plCoverAnchor; anchors.fill: parent }
|
|
}
|
|
|
|
// The playlist cover menu's rows (Add vs Change+Remove flips on
|
|
// hasCustomCover). The store is Qt-first
|
|
// (custom_playlist_covers.json — a `playlists` key inside the
|
|
// shared custom_artwork.json would be dropped on the Slint
|
|
// app's next write).
|
|
QbzContextMenu {
|
|
id: plCoverMenu
|
|
menuWidth: 196
|
|
Repeater {
|
|
id: plCoverRepeater
|
|
model: root.playlistCoverMenuModel
|
|
delegate: Rectangle {
|
|
required property var modelData
|
|
width: parent ? parent.width : 0
|
|
height: 33
|
|
radius: 5
|
|
color: pcmiArea.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: pcmiArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
plCoverMenu.close()
|
|
if (modelData.action === "add") QbzBridge.playlistCoverAdd(doc.id || "")
|
|
else if (modelData.action === "remove") QbzBridge.playlistCoverRemove(doc.id || "")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
CoverLightbox { id: plCoverLightbox }
|
|
|
|
// Metadata.
|
|
Column {
|
|
width: parent.width - 150 - 24
|
|
spacing: 0
|
|
Text {
|
|
text: QbzSession.tr("Playlist", QbzSession.trRev).toUpperCase()
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 1.5
|
|
}
|
|
Item { width: 1; height: 4 }
|
|
Text {
|
|
width: parent.width
|
|
text: doc.name || ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontSection
|
|
font.weight: theme.weightBold
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
Item { visible: (doc.descriptionShort || "") !== ""; width: 1; height: 6 }
|
|
Text {
|
|
visible: (doc.descriptionShort || "") !== ""
|
|
width: Math.min(parent.width, 700)
|
|
text: doc.descriptionShort || ""
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontLegal
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
Text {
|
|
visible: (doc.description || "") !== (doc.descriptionShort || "")
|
|
height: 18
|
|
text: QbzSession.tr("Read more", QbzSession.trRev)
|
|
color: rmArea.containsMouse ? theme.accentHover : theme.accent
|
|
font.pixelSize: theme.fontLegal
|
|
verticalAlignment: Text.AlignVCenter
|
|
MouseArea {
|
|
id: rmArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.openDescription()
|
|
}
|
|
}
|
|
Item { width: 1; height: 8 }
|
|
Text {
|
|
text: (doc.owner || "") + " • " + (doc.trackCount || 0) + " " + QbzSession.tr("tracks", QbzSession.trRev)
|
|
+ " • " + (doc.totalDuration || "")
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontLegal
|
|
}
|
|
Item { width: 1; height: 14 }
|
|
// Actions — buttons left, search + sort floating right.
|
|
Row {
|
|
width: parent.width
|
|
spacing: 12
|
|
Row {
|
|
id: playlistHeaderActions
|
|
width: implicitWidth
|
|
height: 44
|
|
spacing: 12
|
|
QbzCircleAction {
|
|
name: "play-fill"
|
|
primary: true
|
|
btnEnabled: root.allTracks.length > 0
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzBridge.playlistPlayAll()
|
|
}
|
|
QbzCircleAction {
|
|
name: "shuffle"
|
|
btnEnabled: root.allTracks.length > 0
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzBridge.playlistShuffle()
|
|
}
|
|
QbzCircleAction {
|
|
name: root.headerFavorite ? "heart-filled" : "heart"
|
|
active: root.headerFavorite
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzBridge.playlistToggleFavorite()
|
|
}
|
|
// Follow / unfollow and Copy are Qobuz WRITES
|
|
// (`playlist/subscribe`, `playlist/create`): offline
|
|
// they stay visible but inert, matching the reference's
|
|
// read-only treatment.
|
|
QbzCircleAction {
|
|
visible: !root.isOwner
|
|
btnEnabled: root.online
|
|
name: (doc.isFollowing === true) ? "check" : "user-plus"
|
|
active: doc.isFollowing === true
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzBridge.playlistToggleFollow()
|
|
}
|
|
QbzCircleAction {
|
|
visible: !root.isOwner && doc.isCopied !== true
|
|
btnEnabled: root.online
|
|
name: "copy"
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzBridge.playlistCopy()
|
|
}
|
|
// Multi-select toggle (AlbumView's button, here for
|
|
// the playlist's track list).
|
|
QbzCircleAction {
|
|
name: "square-check-big"
|
|
active: root.multiSelect
|
|
btnEnabled: root.tracks.length > 0
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: root.setMultiSelect(!root.multiSelect)
|
|
}
|
|
QbzCircleAction {
|
|
id: playlistHeaderMenuButton
|
|
name: "ellipsis"
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: function (mouse) {
|
|
playlistHeaderMenu.openAtCursor(
|
|
playlistHeaderMenuButton, mouse.x, mouse.y)
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
width: Math.max(0, parent.width
|
|
- playlistHeaderActions.width - 220 - 132 - 3 * 12)
|
|
height: 1
|
|
}
|
|
|
|
// In-playlist search.
|
|
QbzSearchField {
|
|
width: 220
|
|
height: 30
|
|
sidePadding: 9
|
|
glyphSize: 13
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
placeholder: QbzSession.tr("Search tracks", QbzSession.trRev)
|
|
onEdited: function (text) { QbzBridge.playlistSetSearch(text) }
|
|
}
|
|
// Sort dropdown.
|
|
Rectangle {
|
|
width: 132
|
|
height: 34
|
|
radius: 6
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
// PlaylistView.slint:855-859 — resting fill
|
|
// translucent under the dynamic background.
|
|
color: sortArea.containsMouse
|
|
? theme.elevatedHoverFill
|
|
: theme.elevatedFill
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 11
|
|
anchors.rightMargin: 8
|
|
spacing: 5
|
|
Text {
|
|
width: parent.width - 13 - 5
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzSession.tr("Sort", QbzSession.trRev) + ": " + root.sortLabel()
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontLegal
|
|
elide: Text.ElideRight
|
|
}
|
|
QbzIcon { name: "chevron-down"; width: 13; height: 13; anchors.verticalCenter: parent.verticalCenter; tintName: "muted" }
|
|
}
|
|
MouseArea {
|
|
id: sortArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: sortMenu.openBelowRight(sortArea)
|
|
}
|
|
QbzContextMenu {
|
|
id: sortMenu
|
|
menuWidth: 172
|
|
Repeater {
|
|
model: [
|
|
{ "field": "default", "label": QbzSession.tr("Default", QbzSession.trRev) },
|
|
{ "field": "title", "label": QbzSession.tr("Title", QbzSession.trRev) },
|
|
{ "field": "artist", "label": QbzSession.tr("Artist", QbzSession.trRev) },
|
|
{ "field": "album", "label": QbzSession.tr("Album", QbzSession.trRev) },
|
|
{ "field": "duration", "label": QbzSession.tr("Duration", QbzSession.trRev) },
|
|
{ "field": "added", "label": QbzSession.tr("Date added", QbzSession.trRev) },
|
|
// Manual order — enables the per-row
|
|
// reorder chevrons. HIDDEN on a LOCAL
|
|
// playlist (PlaylistView.slint:945-952,
|
|
// B2): its Default order IS the editable
|
|
// repo order, so a separate custom
|
|
// sidecar is moot — and the sidecar is
|
|
// u64-keyed, which a local row's id is
|
|
// not.
|
|
{ "field": "custom", "label": QbzSession.tr("Custom", QbzSession.trRev), "ownerOnly": true, "qobuzOnly": true },
|
|
]
|
|
delegate: Rectangle {
|
|
required property var modelData
|
|
visible: (modelData.ownerOnly !== true || root.isOwner)
|
|
&& (modelData.qobuzOnly !== true || !root.isLocal)
|
|
width: parent ? parent.width : 0
|
|
height: visible ? 33 : 0
|
|
radius: 5
|
|
color: soArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 8
|
|
spacing: 6
|
|
Text {
|
|
width: parent.width - 26
|
|
height: parent.height
|
|
text: modelData.label
|
|
color: theme.textSecondary
|
|
font.pixelSize: 13
|
|
font.weight: root.sortField === modelData.field ? theme.weightSemibold : theme.weightRegular
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
QbzIcon {
|
|
visible: root.sortField === modelData.field
|
|
name: root.sortAsc ? "chevron-up" : "chevron-down"
|
|
width: 12
|
|
height: 12
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
tintName: "accent"
|
|
}
|
|
}
|
|
MouseArea {
|
|
id: soArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
sortMenu.close()
|
|
QbzBridge.playlistSetSort(modelData.field)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Item { width: 1; height: 18 }
|
|
|
|
// Empty. (The loading state is the row placeholders inside the
|
|
// track-list area below — the Slint's bare LoadingSpinner said
|
|
// "busy" but not "this is the shape of what is coming".)
|
|
Text {
|
|
visible: !root.loading && root.allTracks.length === 0
|
|
text: QbzSession.tr("This playlist is empty.", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontBody
|
|
}
|
|
|
|
// --- Column header ---------------------------------------------------
|
|
// rows/TrackListHeader.qml — the same rows/TrackCols.qml geometry the
|
|
// TrackRows below use. Two things were wrong when this was hand-rolled
|
|
// here, and both are now structural:
|
|
// * `anchors.leftMargin: 12` on a Column child is a NO-OP (a
|
|
// positioner owns its children's x), so the labels started 12px
|
|
// left of the row cells; the header component pads itself;
|
|
// * it was as wide as the page while the rows are the ListView's
|
|
// width, which is inset 14px for the scrollbar gutter — 14px of
|
|
// extra stretch pushed Album/Duration/Quality right. The width
|
|
// below is the ROW width, which is the component's contract.
|
|
// Multi-select bulk bar — INLINE above the column header (the Local
|
|
// Library album view's layout: bar in flow, list below).
|
|
QbzMultiSelectBar {
|
|
visible: root.multiSelect && root.tracks.length > 0
|
|
width: parent.width
|
|
selectedCount: root.selectedCount
|
|
actions: [
|
|
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
|
|
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
|
|
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
|
|
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
|
|
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
|
|
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
|
|
{ "id": "add-to-favorites", "label": QbzSession.tr("Add to Library", QbzSession.trRev), "icon": "heart", "danger": false, "needsSelection": true },
|
|
{ "id": "make-offline", "label": QbzSession.tr("Make available offline", QbzSession.trRev), "icon": "cloud-download", "danger": false, "needsSelection": true },
|
|
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }
|
|
]
|
|
onAction: function (id) { root.bulkAction(id) }
|
|
}
|
|
|
|
TrackListHeader {
|
|
visible: root.tracks.length > 0
|
|
width: parent.width - 14
|
|
showArtwork: true
|
|
showSource: root.isLocal || !root.online
|
|
showAlbum: true
|
|
showDownload: true
|
|
downloadGlyph: !root.isLocal
|
|
downloadActionEnabled: !root.isLocal && root.online
|
|
&& root.allTracks.length > 0
|
|
downloadActionLoading: QbzOffline.collectionPreflightLoading
|
|
&& QbzOffline.collectionPreflightKey === "playlist:" + String(root.doc.id || "")
|
|
onDownloadClicked: QbzOffline.cachePlaylist(String(root.doc.id || ""))
|
|
// MIRROR the delegate's arm, or the labels slide by 22 + 14 the
|
|
// moment the list becomes reorderable (TrackListHeader's rule 2).
|
|
showReorder: root.canReorder
|
|
}
|
|
Rectangle { visible: root.tracks.length > 0; width: 1; height: 3; color: "transparent" }
|
|
Rectangle { visible: root.tracks.length > 0; width: parent.width; height: 1; color: theme.borderSubtle }
|
|
Item { width: 1; height: 6 }
|
|
|
|
// --- Track list -------------------------------------------------------
|
|
Item {
|
|
width: parent.width
|
|
height: parent.height - 28 - 150 - 18 - 50 - (root.multiSelect ? 46 : 0)
|
|
|
|
// Track-list placeholder: the exact TrackRow footprint (50px
|
|
// rows, no gap, 36px art cell), one instance for the whole
|
|
// viewport — ONE animator, not one per row (see QbzSkeleton's
|
|
// COST note). It unmounts the moment the first rows land.
|
|
QbzSkeleton {
|
|
visible: root.listPending
|
|
variant: "rowList"
|
|
anchors.fill: parent
|
|
anchors.rightMargin: 14
|
|
rowH: 50
|
|
rowGap: 0
|
|
rowArtSize: 36
|
|
phase: root.skelPhase
|
|
}
|
|
|
|
ListView {
|
|
id: trackList
|
|
anchors.fill: parent
|
|
anchors.rightMargin: 14
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
cacheBuffer: 500
|
|
model: root.tracks
|
|
delegate: TrackRow {
|
|
required property var modelData
|
|
required property int index
|
|
width: parent ? parent.width : 0
|
|
item: modelData
|
|
number: index + 1
|
|
showArtwork: true
|
|
showSource: root.isLocal || !root.online
|
|
showAlbum: true
|
|
artistLink: true
|
|
showDownload: true
|
|
// Alternating row tint, like the local album page
|
|
// (LocalAlbumView.qml). TrackRow already owns the stripe —
|
|
// it paints `#07ffffff` on even `number`s — so this is the
|
|
// whole change.
|
|
zebra: true
|
|
selectMode: root.multiSelect
|
|
checked: root.selected[item.id] === true
|
|
onToggleSelect: function (mods) { root.toggleSelected(item.id, mods) }
|
|
menuShowRemove: root.isOwner
|
|
// Reorder chevrons (owner findings 5 + 8). `index` is the
|
|
// index into `root.tracks`, which under `canReorder` is
|
|
// the unfiltered document — the empty-search term of that
|
|
// predicate is what guarantees it.
|
|
showReorder: root.canReorder
|
|
canMoveUp: index > 0
|
|
canMoveDown: index < root.tracks.length - 1
|
|
onMoveUpRequested: QbzBridge.playlistMoveRow(String(item.id), -1)
|
|
onMoveDownRequested: QbzBridge.playlistMoveRow(String(item.id), 1)
|
|
onPlayRequested: QbzBridge.playlistPlayTrack(item.id)
|
|
onEnqueueRequested: function (m) { QbzBridge.playlistEnqueueTrack(item.id, m) }
|
|
// The DISPLAY row id, as a string — for a Qobuz row the
|
|
// CATALOG id, for a LOCAL row its library rowid / plex key.
|
|
// Each Rust arm resolves it to the target it needs: the
|
|
// Qobuz arm (`playlist_qt::remove_track`) looks up the row's
|
|
// MEMBERSHIP id from it (the two differ since the
|
|
// membership-id split), the local/sidecar arms key their
|
|
// repo removal on it directly. Do NOT send `playlistTrackId`
|
|
// here: on a LOCAL row it is a queue id (or 0 unresolved),
|
|
// which the position map does not know.
|
|
onRemoveRequested: QbzBridge.playlistRemoveTrack(String(item.id))
|
|
// "Find available version" (contract §6.1) — the ONE
|
|
// surface that offers it, and the gate is the reference's:
|
|
// a QOBUZ playlist the signed-in user OWNS. Qobuz refuses
|
|
// a write to anyone else's playlist, so offering it there
|
|
// would be a dead action; a LOCAL playlist's rows are not
|
|
// catalog memberships at all, and a mixed local/plex row in
|
|
// this same view carries its own `source` word, which is
|
|
// why the third term reads the row and not just the doc.
|
|
routeReplaceExternally: root.isOwner && !root.isLocal
|
|
&& (modelData.source || "") === ""
|
|
// Everything Rust needs, in ONE JSON object (the
|
|
// `QbzMyQbzAdd.open(JSON.stringify(...))` idiom). No row
|
|
// INDEX is passed on purpose: `index` here is the index
|
|
// into the DISPLAYED list, which under a search filter or a
|
|
// non-default sort is not the playlist's own order — the
|
|
// reposition step derives the real slot from the
|
|
// authoritative playlist it re-fetches anyway.
|
|
onReplaceRequested: QbzTrackReplace.open(JSON.stringify({
|
|
"playlistId": String(root.doc.id || ""),
|
|
"playlistTrackId": String(item.playlistTrackId || item.id),
|
|
"trackId": String(item.id),
|
|
"title": item.title || "",
|
|
"artist": item.artist || "",
|
|
"album": item.album || "",
|
|
"isrc": item.isrc || "",
|
|
"durationSecs": item.durationSecs || 0
|
|
}))
|
|
// "Add to playlist" — this view serves BOTH a Qobuz
|
|
// playlist and a LOCAL one (`local_playlist_qt::load`
|
|
// publishes into this same document through
|
|
// `playlist_qt::adopt_doc`), so the row's id space is not
|
|
// uniform here and the routing reads the row's own
|
|
// `source` word ("" = Qobuz, "local" | "plex" = a
|
|
// library.db / Plex ref). The local arm hands the DISPLAY
|
|
// id to Rust, which resolves it through
|
|
// `local_playlist_qt::local_picker_ref_for_row` — a Plex
|
|
// row's rating key only exists in the open detail's queue
|
|
// snapshot and QML cannot build the ref itself.
|
|
routePlaylistAddExternally: modelData.source === "local"
|
|
|| modelData.source === "plex"
|
|
onPlaylistAddRequested: QbzPlaylistPicker.openForLocalRow(item.id)
|
|
// MyQBZ "Add to mixtape" — the HOST builds the AddItem
|
|
// array (TrackRow does not know itemType/source).
|
|
//
|
|
// SOURCE comes off the ROW, never a literal: a Qobuz
|
|
// playlist's rows carry no `source` and are catalog rows,
|
|
// while a LOCAL playlist's rows carry "local" | "plex"
|
|
// (playlist_qt.rs PlaylistTrackRow.source). "plex" folds
|
|
// into "local" because `AddItem.source` is
|
|
// "qobuz" | "local" and `source_from_str`
|
|
// (myqbz_add_qt.rs:85-90) maps anything that is not
|
|
// "local" back to Qobuz — passing "plex" verbatim would
|
|
// store a Plex row under a catalog id again.
|
|
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
|
|
"itemType": "track",
|
|
"source": (item.source === "local" || item.source === "plex")
|
|
? "local" : "qobuz",
|
|
"sourceItemId": item.id, "title": item.title || "",
|
|
"subtitle": item.artist || "", "artworkUrl": item.artUrl || "",
|
|
"year": null, "trackCount": null
|
|
}]))
|
|
onBodyDragStarted: function (n) {
|
|
// #589: report the source index BEFORE the shared drag.
|
|
// Gated on `canReorder`, not on ownership alone: under
|
|
// a computed sort (or an active search) this drag is
|
|
// ONLY the add-to-a-sidebar-playlist gesture, and
|
|
// claiming a source index would let a release inside
|
|
// the list commit a move against an order that is not
|
|
// the stored one.
|
|
if (root.canReorder) {
|
|
root.reorderFrom = index
|
|
root.reorderOver = -1
|
|
root.reorderDropPlaylist = ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drop indicator: a 2px accent line at the insertion slot,
|
|
// hidden on the no-op slots and while outside the list.
|
|
Rectangle {
|
|
visible: root.reorderFrom >= 0
|
|
&& root.reorderOver >= 0
|
|
&& root.reorderOver !== root.reorderFrom
|
|
&& root.reorderOver !== root.reorderFrom + 1
|
|
x: 0
|
|
y: Math.max(0, Math.min(parent.height - 2,
|
|
root.reorderOver * 50 - trackList.contentY - 1))
|
|
width: parent.width - 14
|
|
height: 2
|
|
radius: 1
|
|
color: theme.accent
|
|
}
|
|
|
|
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
|
// this container's offset while it is the live page, and restores it
|
|
// when a back/forward step arms this route.
|
|
ScrollMemory { target: trackList; scope: "playlist" }
|
|
QbzScrollBar {
|
|
target: trackList
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 4
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
}
|
|
}
|
|
}
|
|
}
|