Ports the whole playlist-organisation domain, which had no counterpart in this frontend, plus the Library work the owner asked not to defer. Playlist Manager. New controller (playlist_manager_qt, _rows, _ops) over the already-ported folders_qt repository, which until now had zero consumers. Grid, list and folder-tree modes; sort, filter and search; folder cards and chips; drag reorder and up/down arrows. Reached from the sidebar's "..." menu, whose New folder / Manage playlists / Import rows had been rendering dimmed and inert behind a comment saying the seam did not exist. Folders. QbzFolderEdit plus the create/edit modals and PmFolderIcon: preset or custom-image icons, colour, hidden flag, delete. Deleting a folder nulls its LOCAL members' folder_id explicitly, because the app keeps the foreign_keys pragma off, so the schema's ON DELETE SET NULL never fires for them. QbzPlaylistEdit. One shared rename/description/delete editor replacing the inline popup in PlaylistView. Three corrections the old path had wrong: the description is carried through a rename instead of being written as NULL, a FOLLOWED playlist routes to unsubscribe (playlist/delete returns 200 and silently no-ops on a playlist you do not own), and the post-delete back navigation is conditional on actually being on that playlist's page, so deleting from the manager no longer navigates you off it. Importer. Spotify / Apple Music / Tidal / Deezer, over the shared qbz-playlist-import crate. Picker local mode. The reference's four-case matrix — target local or Qobuz, payload local refs or Qobuz ids. Local playlists list unconditionally and Qobuz ones only when online. Local refs onto a Qobuz playlist write the library.db sidecar at next_playlist_sidecar_position(pid, qobuz_count); the 0-based enumerate the reference used before collided slots and lost rows. The carried payload can no longer be parsed as a catalog id when it is a library row id. Unblocks the local bulk add-to-playlist. Library. A-Z jump strip, Albums list mode, Artists side panel, per-tab persistence and multi-select. Preferences land in the same <data-dir>/qbz/favorites_ui.json the shipping build writes, so switching between the two frontends keeps them. LibraryView is split into views/library/ on the way past 1800 lines. Local library. Group-by now persists; playback queues the order on screen rather than the SQL page order; switching album-identity mode invalidates the Artists tab; artist matching folds diacritics and compares normalized credit parts, so "Air" stops listing "Airbourne" and "Beyonce" stops being two artists. Offline. activate_offline never persisted a last-user marker, so every per-user path resolved to None and all local reads came back empty; a session that started offline saw no local playlists at all. The sidebar's load-once latch was function-local and therefore unreachable from the logout reset, so a second login never rebuilt the tree. Both are session-scoped now, and the sidebar Refresh row runs the local-safe verb instead of being dimmed with no way to recover. Also fixes, found while verifying rather than reported: artwork derivatives were written straight to their final path, so a QML Image resolving one mid-encode opened a truncated png and Qt cached that failure against the URL for the rest of the session; Library's track group separators referenced a Component declared in another delegate's scope and could never render; the genre filter was drawn on Tracks and Albums but applied only on All; an All-feed row called a player method that does not exist; and the sidebar's drop-hot highlight used Slint's #RRGGBBAA literals in Qt's #AARRGGBB slot. 17 new msgids across all 8 locales. cc
231 lines
9.7 KiB
QML
231 lines
9.7 KiB
QML
// Mini-rail folder flyout — the port of shell/SidebarFolderPopup.slint.
|
|
//
|
|
// In the 64px rail a folder's nested rows collapse to height 0, so expanding
|
|
// one in place would be a control that changes nothing on screen. Clicking a
|
|
// folder opens THIS panel to its right instead, listing that folder's
|
|
// playlists (four visible, the rest scroll).
|
|
//
|
|
// Extracted out of Sidebar.qml (block 6 of the playlist-manager contract). Two
|
|
// things changed in the move, both fixes rather than transcription:
|
|
//
|
|
// 1. The rows come from a DEDICATED document, QbzShell.sidebarFolderPopupJson
|
|
// (contract §4.7), built in Rust from the sidebar CACHE. The old inline
|
|
// version filtered the flattened `entries`, which only carry a folder's
|
|
// children while that folder is EXPANDED — so it had to force-expand the
|
|
// folder on the way in, and the folder then stayed open once the sidebar
|
|
// was re-opened. The reference has no such side effect.
|
|
// 2. The name and the count still come SYNCHRONOUSLY from the clicked entry.
|
|
// The document arrives a LATER event-loop turn (the cxx-qt UI queue), so
|
|
// binding the header to it would show folder A's name while B is opening;
|
|
// `rows` is therefore ignored until the document's own folderId matches
|
|
// the one this panel was opened for.
|
|
//
|
|
// Geometry is 1:1 with SidebarFolderPopup.slint: 230px wide, 34 + 1 + rows*32
|
|
// + 8 tall (the 34 is deliberately 4px more than the 30px header it holds —
|
|
// kept, not tightened), surface-main / Radius.sm / 1px border-muted, 4px inner
|
|
// padding, 30px header with a 14px accent folder-open glyph, a 1px hairline,
|
|
// then 32px rows with a 14px muted list-music glyph.
|
|
//
|
|
// The list-music glyph is drawn for a LOCAL playlist too: SidebarFolderPopup
|
|
// .slint has no local-kind branch (unlike SidebarRow), so the hard-drive mark
|
|
// is lost inside the flyout. That is the reference — recorded, not an omission.
|
|
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import com.blitzfc.qbz
|
|
import "../theme"
|
|
|
|
Popup {
|
|
id: root
|
|
|
|
// Set SYNCHRONOUSLY by openFor() from the clicked entry — never from the
|
|
// document (see the header note).
|
|
property string folderId: ""
|
|
property string folderName: ""
|
|
property int folderCount: 0
|
|
|
|
/// The sidebar owns `activePlaylistId` (the row highlight); a component
|
|
/// cannot reach another file's ids, so it is told rather than assumed.
|
|
signal playlistActivated(string id)
|
|
|
|
width: 230
|
|
padding: 0
|
|
topPadding: 4
|
|
bottomPadding: 4
|
|
// Slint's literal panel height (34 + 1 + list + 8) — 4px taller than the
|
|
// content, kept 1:1 rather than tightened.
|
|
height: 34 + 1 + root.visibleRows * 32 + 8
|
|
// Replaces Slint's full-window scrim TouchArea (SidebarFolderPopup.slint:
|
|
// 56-60); the Popup's own background swallows clicks inside, so no
|
|
// hand-built scrim and no click-through.
|
|
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
|
|
|
|
// Guarded parse — a raw JSON.parse in a binding throws on the pre-publish
|
|
// frame. The fallback carries the FULL shape, so `.rows.length` is safe.
|
|
readonly property var doc: {
|
|
try {
|
|
return JSON.parse(QbzShell.sidebarFolderPopupJson)
|
|
} catch (e) {
|
|
return ({ "folderId": "", "folderName": "", "count": 0, "rows": [] })
|
|
}
|
|
}
|
|
// Only this folder's document counts. Without the guard, opening folder B
|
|
// while A's document is still the published one lists A's playlists.
|
|
readonly property var rows:
|
|
(root.doc.folderId === root.folderId && root.doc.rows) ? root.doc.rows : []
|
|
// Height off the folder's own count (known at click time) so the panel
|
|
// does not resize when the rows land a tick later; `max` because the
|
|
// entry's count is computed post-search and the document's is not.
|
|
readonly property int visibleRows:
|
|
Math.min(Math.max(root.folderCount, root.rows.length), 4)
|
|
|
|
/// Open beside `anchorItem` (the rail row), +6px to its right and aligned
|
|
/// to its top — SidebarFolderPopup's anchor-x / anchor-y.
|
|
///
|
|
/// `entry` is the SidebarEntry the row carries: {id, name, count, …}.
|
|
function openFor(entry, anchorItem) {
|
|
root.folderId = entry.id
|
|
root.folderName = entry.name
|
|
root.folderCount = entry.count
|
|
// Fill the rows from the cache. No expand side effect (header note 1).
|
|
QbzShell.sidebarOpenFolderPopup(entry.id)
|
|
var p = anchorItem.mapToItem(root.parent, anchorItem.width + 6, 0)
|
|
root.x = p.x
|
|
root.y = p.y
|
|
root.open()
|
|
}
|
|
|
|
background: Rectangle {
|
|
color: theme.surfaceMain
|
|
radius: theme.radiusSm
|
|
border.width: 1
|
|
border.color: theme.borderMuted
|
|
}
|
|
|
|
contentItem: Column {
|
|
spacing: 0
|
|
|
|
// The theme tokens. It lives HERE, inside the content tree, because a
|
|
// QML file has no lexical scope across files and this Popup's own
|
|
// default property is `contentData` — a positioner ignores a plain
|
|
// QtObject in its data, so nothing is laid out for it.
|
|
QbzTheme { id: theme }
|
|
|
|
// Header — folder name (30px, 12px side padding, 14px accent
|
|
// folder-open glyph, 13px/600 text-primary).
|
|
Item {
|
|
width: parent.width
|
|
height: 30
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 12
|
|
anchors.rightMargin: 12
|
|
spacing: 8
|
|
QbzIcon {
|
|
name: "folder-open"
|
|
width: 14
|
|
height: 14
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
tintName: "accent"
|
|
}
|
|
Text {
|
|
width: parent.width - 22
|
|
height: parent.height
|
|
text: root.folderName
|
|
color: theme.textPrimary
|
|
font.pixelSize: 13
|
|
font.weight: theme.weightSemibold
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 1
|
|
color: theme.borderSubtle
|
|
}
|
|
|
|
// Playlist list — four rows visible, the rest scroll (the shared
|
|
// QbzScrollBar mounts past four, like Slint's ListScrollbar).
|
|
//
|
|
// Deliberately NOT a ListView: the panel shows at most four rows of a
|
|
// single folder, the reference is a Flickable over a VerticalLayout,
|
|
// and the recycling a ListView buys is worth nothing at that size.
|
|
Item {
|
|
width: parent.width
|
|
height: root.visibleRows * 32
|
|
|
|
Flickable {
|
|
id: fpFlick
|
|
anchors.fill: parent
|
|
clip: true
|
|
contentWidth: width
|
|
contentHeight: fpColumn.implicitHeight
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
Column {
|
|
id: fpColumn
|
|
width: parent.width
|
|
Repeater {
|
|
model: root.rows
|
|
delegate: Rectangle {
|
|
id: fpRow
|
|
required property var modelData
|
|
width: fpColumn.width
|
|
height: 32
|
|
radius: 5
|
|
color: fpArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 12
|
|
anchors.rightMargin: 12
|
|
spacing: 8
|
|
QbzIcon {
|
|
name: "list-music"
|
|
width: 14
|
|
height: 14
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
tintName: "muted"
|
|
}
|
|
Text {
|
|
width: parent.width - 22
|
|
height: parent.height
|
|
text: fpRow.modelData.name
|
|
color: theme.textSecondary
|
|
font.pixelSize: 13
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
MouseArea {
|
|
id: fpArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
// `local:` ids survive untouched —
|
|
// openPlaylist routes them to the local
|
|
// loader and works offline.
|
|
root.playlistActivated(fpRow.modelData.id)
|
|
QbzBridge.openPlaylist(fpRow.modelData.id)
|
|
root.close()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
QbzScrollBar {
|
|
visible: Math.max(root.folderCount, root.rows.length) > 4
|
|
&& fpFlick.contentHeight > fpFlick.height
|
|
target: fpFlick
|
|
width: 10
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
}
|
|
}
|
|
}
|
|
}
|