Files
qbz/crates/qbz-qt/qml/views/PlaylistView.qml
T
vicrodh aabde3be63 fix(qbz-qt): unresolvable QML imports, focus-based freezing, and dock render cost
Three views instantiated components the phase-23 directory move had left
unimportable: PlaylistView and SearchView use TrackRow (now in rows/) and
SectionRail uses AlbumCard (now in cards/). QML resolves lazily, so these
survived cargo check, the test suite and the offscreen smoke — they only
failed when the user opened a playlist. Adds a repo-wide audit of every
instantiated component against the importing file's visible directories; the
three above were all of them.

The ambient field and the new dock band froze on lost focus. That is wrong on
any tiling or mosaic desktop, where windows stay fully visible while
unfocused — the field would stop while the user is looking at it. Both now
gate on the window not being minimized or hidden, which is the real "nobody
can see this". Freezing on not-visible stays.

Dock band render cost, in descending order of impact: the drain publishes
only the stream the active mode consumes (Bars mode was marshalling a
512-float waveform QList 30 times a second for nothing); each mode marshals
its QList into JS once per tick instead of once per delegate; one shared
Gradient replaces 28 per-delegate instances; and the per-bar Behaviors are
gone since qbz-audio already smooths and clamps the amplitudes upstream.

18/18 tests; offscreen smoke clean; component-resolution audit clean.

cc
2026-07-28 12:26:40 -06:00

605 lines
27 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 (server image, else first-track cover, else 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 (owned, wired) / pin (wired) /
// follow (foreign, subscribe API) / copy (foreign, create+add) / edit
// (owner: rename + delete modal). 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 / Go to artist / Go to album / Add|Remove from Library / Remove
// from playlist (owner).
//
// 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.
//
// POC-NOTEs (playlist_qt.rs has the full list): local playlists,
// custom-cover set/clear, multi-select + bulk bar, Suggested Songs,
// offline download, share — not ported.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../rows"
import "../theme"
Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
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
readonly property string sortField: doc.sortField || "default"
readonly property bool sortAsc: doc.sortAsc === true
readonly property string searchQuery: doc.search || ""
// 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
})
}
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)
}
// --- 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 ---------------------------------------------------------
Row {
width: parent.width
spacing: 24
// Cover.
Rectangle {
width: 150
height: 150
radius: theme.radiusMd
color: theme.surfaceElevated
clip: true
RoundedImage {
visible: (doc.coverPath || "") !== ""
anchors.fill: parent
source: doc.coverPath || ""
radius: theme.radiusMd
}
QbzIcon {
visible: (doc.coverPath || "") === ""
name: "list-music"
width: 56
height: 56
anchors.centerIn: parent
tintName: "muted"
}
}
// 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
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: (doc.isFavorite === true) ? "heart-filled" : "heart"
active: doc.isFavorite === true
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzBridge.playlistToggleFavorite()
}
QbzCircleAction {
name: (doc.pinned === true) ? "pin-filled" : "pin"
active: doc.pinned === true
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzBridge.playlistTogglePin()
}
QbzCircleAction {
visible: !root.isOwner
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
name: "copy"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzBridge.playlistCopy()
}
QbzCircleAction {
visible: root.isOwner
name: "pen-line"
anchors.verticalCenter: parent.verticalCenter
onClicked: editModal.open()
}
Item { width: parent.width - 40 - 32 * 6 - 7 * 12 - 220 - 140; height: 1 }
// In-playlist search.
Rectangle {
width: 220
height: 30
radius: 6
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Row {
anchors.fill: parent
anchors.leftMargin: 9
anchors.rightMargin: 9
spacing: 6
QbzIcon { name: "search"; width: 13; height: 13; anchors.verticalCenter: parent.verticalCenter; tintName: "muted" }
TextInput {
width: parent.width - 19
height: parent.height
color: theme.textPrimary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
clip: true
onTextEdited: QbzBridge.playlistSetSearch(text)
Text {
visible: parent.text === ""
anchors.fill: parent
text: QbzSession.tr("Search tracks", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
}
}
// Sort dropdown.
Rectangle {
width: 132
height: 34
radius: 6
anchors.verticalCenter: parent.verticalCenter
color: sortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
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) },
{ "field": "custom", "label": QbzSession.tr("Custom", QbzSession.trRev), "ownerOnly": true },
]
delegate: Rectangle {
required property var modelData
visible: modelData.ownerOnly !== true || root.isOwner
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 }
// Loading / empty.
QbzSpinner {
visible: root.loading
anchors.horizontalCenter: parent.horizontalCenter
size: 36
}
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 ---------------------------------------------------
Row {
visible: root.tracks.length > 0
width: parent.width
anchors.leftMargin: 12
spacing: 14
Text { text: "#"; width: 32; color: theme.textMuted; font.pixelSize: theme.fontLegal; horizontalAlignment: Text.AlignHCenter }
Rectangle { width: 36; height: 1; color: "transparent" }
Text { text: QbzSession.tr("Title", QbzSession.trRev); width: parent.width - 32 - 36 - 220 - 70 - 92 - 28 - 28 - 32 - 8 * 14; color: theme.textMuted; font.pixelSize: theme.fontLegal }
Text { text: QbzSession.tr("Album", QbzSession.trRev); width: 220; color: theme.textMuted; font.pixelSize: theme.fontLegal }
Text { text: QbzSession.tr("Duration", QbzSession.trRev); width: 70; color: theme.textMuted; font.pixelSize: theme.fontLegal; horizontalAlignment: Text.AlignHCenter }
Text { text: QbzSession.tr("Quality", QbzSession.trRev); width: 92; color: theme.textMuted; font.pixelSize: theme.fontLegal; horizontalAlignment: Text.AlignHCenter }
Rectangle { width: 28; height: 1; color: "transparent" }
Rectangle { width: 28; height: 1; color: "transparent" }
Rectangle { width: 32; height: 1; color: "transparent" }
}
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
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
showAlbum: true
showDownload: true
menuShowRemove: root.isOwner
onPlayRequested: QbzBridge.playlistPlayTrack(item.id)
onEnqueueRequested: function (m) { QbzBridge.playlistEnqueueTrack(item.id, m) }
onRemoveRequested: QbzBridge.playlistRemoveTrack(item.playlistTrackId)
onBodyDragStarted: function (n) {
// #589: report the source index BEFORE the shared drag.
if (root.isOwner) {
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
}
QbzScrollBar {
target: trackList
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
// --- Edit modal (rename + delete; owner) ---------------------------------
Popup {
id: editModal
parent: Overlay.overlay
anchors.centerIn: parent
width: 380
padding: 20
closePolicy: Popup.CloseOnEscape
background: Rectangle {
color: theme.surfaceCard
radius: theme.radiusMd
border.width: 1
border.color: theme.borderSubtle
}
contentItem: Column {
spacing: 14
Text {
text: QbzSession.tr("Edit playlist", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
Rectangle {
width: parent.width
height: 36
radius: 6
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
TextInput {
id: nameInput
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
text: root.doc.name || ""
color: theme.textPrimary
font.pixelSize: 14
verticalAlignment: Text.AlignVCenter
clip: true
}
}
Row {
spacing: 10
anchors.right: parent.right
Rectangle {
width: delText.implicitWidth + 28
height: 34
radius: 6
color: delArea.containsMouse ? "#33ef4444" : "transparent"
border.width: 1
border.color: "#66ef4444"
Text {
id: delText
anchors.centerIn: parent
text: QbzSession.tr("Delete", QbzSession.trRev)
color: "#ef4444"
font.pixelSize: 13
font.weight: theme.weightMedium
}
MouseArea {
id: delArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
editModal.close()
QbzBridge.playlistDelete()
}
}
}
Rectangle {
width: saveText.implicitWidth + 28
height: 34
radius: 6
color: saveArea.containsMouse ? theme.accentHover : theme.accent
Text {
id: saveText
anchors.centerIn: parent
text: QbzSession.tr("Save", QbzSession.trRev)
color: "#ffffff"
font.pixelSize: 13
font.weight: theme.weightMedium
}
MouseArea {
id: saveArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
editModal.close()
QbzBridge.playlistRename(nameInput.text)
}
}
}
}
}
}
}