feat(qt): playlist manager, folders, importer and the Library parity round

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
This commit is contained in:
vicrodh committed 2026-07-31 22:10:19 -06:00
1 parent ac9740b83a
commit a16cf56c86
115 files changed
+20778 -1637

No files matched your search

+48 -1
View File
@@ -193,7 +193,13 @@ Rectangle {
: QbzShell.currentView === "discobuilder" ? "../views/myqbz/DiscoBuilderView.qml"
// Settings > Blacklist > Manage. Not reachable from the
// sidebar — blacklist_qt::open_manager records the route.
: QbzShell.currentView === "blacklist" ? "../views/BlacklistManagerView.qml" : ""
: QbzShell.currentView === "blacklist" ? "../views/BlacklistManagerView.qml"
// Sidebar > Playlists ⋯ > Manage playlists. Not a sidebar
// section — playlist_manager_qt::navigate() records the route.
// The route is a TWO-FILE contract (nav_qt.rs:9-16): the caller
// records the id, this arm mounts it, and the failure mode for a
// missing arm is a BLANK content pane, logged nowhere.
: QbzShell.currentView === "playlistmanager" ? "../views/PlaylistManagerView.qml" : ""
}
// MyQbzGridView serves BOTH the "mixtapes" and "collections" routes
@@ -548,9 +554,50 @@ Rectangle {
AddToMixtapeModal {
anchors.fill: parent
}
// PlaylistPickerModal — the "Add to playlist" picker, opened from the
// queue footer (save the queue as a playlist) and the queue row menu.
// Same reason it lives out here as its Mixtape sibling: the surface that
// opened it is often gone by the time the user picks a target, and the
// queue panel in particular is `visible`-gated.
PlaylistPickerModal {
anchors.fill: parent
}
// PlaylistImportModal — the public-playlist importer (Spotify / Apple
// Music / Tidal / Deezer), opened from the sidebar `...` menu and from the
// closed-sidebar flyout. It mounts HERE and not in the sidebar by explicit
// contract (05 §5.8.5): closing it must not cancel an in-flight import, and
// neither surface that opens it has a lifetime guarantee across a
// navigation — the importer's own completion arm navigates to the imported
// playlist while the modal is still up.
PlaylistImportModal {
anchors.fill: parent
}
MyQbzModals {
anchors.fill: parent
}
// FolderModals — the "New folder" create panel, the full folder editor and
// their delete confirm (contract D21). Out here for the same reason as its
// neighbours: it is opened from the SIDEBAR's "..." menu and row menu as
// well as from the Playlist Manager view, and the sidebar animates to
// width 0 with clip: true, so a modal parented into it would disappear
// with it. Self-gates on QbzFolderEdit.createJson / editJson, so while
// both are closed it is an invisible, non-interactive Item.
FolderModals {
anchors.fill: parent
}
// PlaylistEditModal — the ONE playlist editor (rename · description ·
// offline-only · delete), for both `local:` and Qobuz playlists (contract
// D20/D21). Out here for the same reason as its neighbours: it is opened
// from the manager's cards and rows, from the SIDEBAR's row context menu
// and from the playlist detail header — and its delete arm navigates away
// from the page that raised it. It replaces the inline Popup that used to
// live in views/PlaylistView.qml, which could express neither a
// description nor the offline-only flag. Self-gates on
// QbzPlaylistEdit.editJson, so while closed it is an invisible,
// non-interactive Item.
PlaylistEditModal {
anchors.fill: parent
}
QbzToast {
anchors.fill: parent
}
+125 -4
View File
@@ -26,6 +26,22 @@
// POC-NOTE: the custom window-chrome parts (drag surface, drawn
// min/max/close WindowControls) are skipped — the POC keeps NATIVE window
// decorations.
//
// FILE LENGTH (>500): this is ONE bar with one placement state machine —
// `navInSidebar` x `navHeaderCompact` x the three sidebar widths decide which
// of the header's forms is mounted, and every block below reads that same
// state. The parts that are self-contained have already been extracted and are
// SHARED with the sidebar rather than duplicated: the section dropdown
// (shell/NavFlyout.qml), its leading glyph (shell/NavSectionGlyph.qml), the
// nav buttons (controls/QbzNavButton.qml) and the hover bubble
// (controls/QbzTooltip.qml, mounted once by AppShell). What remains is the
// bar's own layout — left controls, the absolutely-centred search, the status
// badge, the window controls and the app menu — each a positional slot of this
// single row, so splitting them would hand every half the same placement
// properties to keep in sync while none of them is reusable anywhere else.
// The closed-sidebar playlists flyout is the one candidate left: it is
// self-contained, but it reads `plBtn` for its anchor and the sidebar's shared
// search query, so it moves the day a second surface needs it.
import QtQuick
import QtQuick.Controls
@@ -331,10 +347,16 @@ Rectangle {
// 248px wide, a search header over the SAME Rust-side filter
// the sidebar uses, then the flat entries (folders toggle in
// place, playlists open and close the flyout). Six rows
// visible, the rest scroll.
// GAP: Slint's footer (Import / Manage playlists) is omitted —
// this port has neither seam, and the sidebar "..." menu marks
// the same two as unavailable rather than faking them.
// visible, the rest scroll, then Slint's two footer rows —
// Import and Manage playlists.
//
// Those two call the IDENTICAL invokables the sidebar "..."
// menu's rows do, and both surfaces close themselves and clear
// the SHARED search query before acting (this popup's onClosed
// already does the clearing). Wiring only one of them would
// re-create the gap this comment used to describe: the flyout
// is the only playlists surface a user with a closed sidebar
// has, so an entry point missing here is missing outright.
Popup {
id: plPopup
y: plBtn.height + 6
@@ -480,6 +502,105 @@ Rectangle {
}
}
}
// ---- Footer (SidebarPlaylistsPopup.slint:189-269) ---
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
// Import — offline-gated exactly like the sidebar's
// "..." row. The importer modal carries its own offline
// banner as the second layer; the row alone is not the
// gate.
Rectangle {
id: plImportRow
readonly property bool rowEnabled: !QbzSession.offline
width: parent.width
height: 40
radius: 5
color: (plImportRow.rowEnabled && plImportArea.containsMouse)
? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 8
QbzIcon {
name: "import"
width: 15
height: 15
anchors.verticalCenter: parent.verticalCenter
tintName: plImportRow.rowEnabled ? "secondary" : "muted"
}
Text {
width: parent.width - 23
height: parent.height
text: QbzSession.tr("Import", QbzSession.trRev)
color: plImportRow.rowEnabled ? theme.textSecondary : theme.textMuted
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: plImportArea
anchors.fill: parent
enabled: plImportRow.rowEnabled
hoverEnabled: plImportRow.rowEnabled
cursorShape: plImportRow.rowEnabled
? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
QbzPlaylistImport.open()
// Closing clears the shared search query
// (plPopup.onClosed) so it does not silently
// scope the expanded sidebar's list later.
plPopup.close()
}
}
}
// Manage playlists — always available; the manager is a
// local-store surface and the only place a hidden
// playlist can be un-hidden.
Rectangle {
id: plManageRow
width: parent.width
height: 40
radius: 5
color: plManageArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 8
QbzIcon {
name: "library-big"
width: 15
height: 15
anchors.verticalCenter: parent.verticalCenter
tintName: "secondary"
}
Text {
width: parent.width - 23
height: parent.height
text: QbzSession.tr("Manage playlists", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: plManageArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
QbzPlaylistManager.navigate()
plPopup.close()
}
}
}
}
}
}
+17 -4
View File
@@ -495,11 +495,23 @@ Rectangle {
"icon": root.npFavorite ? "heart-filled" : "heart",
"action": "favorite"
},
{ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "action": "playlist" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
]
// "Add to playlist" is SECOND (TransportControls.slint:143 — the
// full bar carries the rationale). Same `npSource` gate as the
// mixtape entry: the picker's Qobuz arm takes catalog ids and an
// `isLocal` current row is four different id spaces this bar
// cannot tell apart, so unknown provenance drops the entry rather
// than rendering one that cannot work.
if (root.npSource === "qobuz") {
m.splice(1, 0, {
"label": QbzSession.tr("Add to playlist", QbzSession.trRev),
"icon": "list-music",
"action": "playlist"
})
}
// Only when the track's source is KNOWN (see `npSource`).
if (root.npSource !== "") {
m.push({
@@ -523,6 +535,10 @@ Rectangle {
if (id !== "") QbzQueue.queueToggleFavorite("track", id)
} else if (a === "queue" || a === "later" || a === "next") {
if (id !== "") QbzPlayer.enqueueTrack(id, a)
} else if (a === "playlist") {
// Only built when `npSource === "qobuz"`, so `id` is a catalog
// id here by the gate above.
if (id !== "") QbzPlaylistPicker.openForTrack(id)
} else if (a === "album-favorite") {
if (QbzPlayer.npAlbumId !== "")
QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId)
@@ -540,9 +556,6 @@ Rectangle {
"year": null, "trackCount": null
}]))
}
// TODO(qt-bridge): "playlist" (add-to-playlist modal) has no
// invokable in the Qt port yet — the row is rendered 1:1 with the
// Slint flyout and does nothing for now.
}
}
}
+19 -4
View File
@@ -561,11 +561,25 @@ Rectangle {
"icon": root.npFavorite ? "heart-filled" : "heart",
"action": "favorite"
},
{ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "action": "playlist" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
]
// "Add to playlist" sits SECOND in TransportControls.slint:143 —
// spliced in there rather than appended, because the flyout's
// order is part of the parity. It rides the SAME `npSource` gate
// as "Add to mixtape" and for the same reason: the picker's Qobuz
// arm takes catalog ids, and an `isLocal` current row is any of
// four different id spaces this bar cannot tell apart (see
// `npSource`). Unknown provenance -> the entry is ABSENT, never
// rendered-and-inert.
if (root.npSource === "qobuz") {
m.splice(1, 0, {
"label": QbzSession.tr("Add to playlist", QbzSession.trRev),
"icon": "list-music",
"action": "playlist"
})
}
// Only when the track's source is KNOWN (see `npSource`): a row we
// cannot address is not offered.
if (root.npSource !== "") {
@@ -590,6 +604,10 @@ Rectangle {
if (id !== "") QbzQueue.queueToggleFavorite("track", id)
} else if (a === "queue" || a === "later" || a === "next") {
if (id !== "") QbzPlayer.enqueueTrack(id, a)
} else if (a === "playlist") {
// The entry only exists when `npSource === "qobuz"`, so `id`
// is a catalog id here by the gate above.
if (id !== "") QbzPlaylistPicker.openForTrack(id)
} else if (a === "album-favorite") {
if (QbzPlayer.npAlbumId !== "")
QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId)
@@ -608,9 +626,6 @@ Rectangle {
"year": null, "trackCount": null
}]))
}
// TODO(qt-bridge): "playlist" (add-to-playlist modal) has no
// invokable in the Qt port yet — the row is rendered 1:1 with the
// Slint flyout and does nothing for now.
}
}
}
+380 -33
View File
@@ -2,16 +2,19 @@
// 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, 40-row pagination,
// live search filter, row actions (play / remove / remove-all-after /
// #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 / stubs + search field), History tab
// (thumbnail rows, click replays as a fresh single-track queue), exact
// empty states. Data: QbzQueue.queueJson (queue_qt.rs QueueDoc).
// 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.
//
// POC-NOTEs: save-as-playlist (opens a picker modal upstream — inert),
// infinite-play + sleep timer engines, stop-after marker, row menu's
// "Add to playlist" / "Track info" entries, ephemeral rows.
// 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
@@ -32,6 +35,34 @@ Rectangle {
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. `onDocChanged` alone is not enough: the
// panel is `visible`-gated rather than mounted on demand, and a queue
// that was published while it was hidden leaves the covers never
// requested — measured live, a local track's now-playing thumbnail
// stayed blank with NO artwork dispatch after the publish. The
// publish dedup means `queuePanelOpened` above often changes nothing,
// so it cannot be relied on to trigger the doc change either.
dispatchCovers()
}
// --- Favourite state, id-keyed (the ArtistView.localToggles idiom) ---
// The rows here are plain JS objects parsed out of `doc`, so the old
@@ -240,15 +271,33 @@ Rectangle {
anchors.bottomMargin: 4
spacing: 9
// Leading: track number (UP NEXT) or thumbnail (History).
Text {
// 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
anchors.verticalCenter: parent.verticalCenter
text: rowIndex + 1 + (doc.page || 0) * 40
color: theme.textMuted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
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
@@ -346,12 +395,40 @@ Rectangle {
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: [
{ "label": QbzSession.tr("Remove from queue", QbzSession.trRev), "icon": "trash-2", "action": "remove" },
{ "label": QbzSession.tr("Remove all after", QbzSession.trRev), "icon": "list-x", "action": "remove-after" },
{ "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" },
]
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
@@ -382,7 +459,10 @@ Rectangle {
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)
}
}
@@ -501,6 +581,27 @@ Rectangle {
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 {
@@ -637,7 +738,11 @@ Rectangle {
}
}
Column {
width: parent.width - 34 - 28 - 2 * 9
// 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 {
@@ -656,8 +761,12 @@ Rectangle {
elide: Text.ElideRight
}
}
// Favorite toggle (wired).
// 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
@@ -797,6 +906,20 @@ Rectangle {
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
}
}
}
@@ -880,24 +1003,248 @@ Rectangle {
readonly property int contentWidth:
width - leftPadding - rightPadding
// Action 1 — Clear queue (wired).
// Action 1 — Clear queue.
QbzIconButton { btnSize: 30
name: "trash-list"
onClicked: QbzQueue.queueClear()
}
// Action 2 — Save as playlist (INERT: opens a picker modal
// upstream — POC-NOTE).
QbzIconButton { btnSize: 30; name: "add-to-list" }
// Action 3 — infinite play (INERT engine — POC-NOTE).
QbzIconButton { btnSize: 30; name: "infinity" }
// Action 4 — sleep timer (INERT engine — POC-NOTE).
QbzIconButton { btnSize: 30; name: "clock" }
// 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 (5 gaps of spacingXs).
// 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 - 4 * 30 - 30
- 5 * theme.spacingXs)
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.
+221 -205
View File
@@ -15,13 +15,18 @@
// flyout to the right (SidebarFolderPopup.slint) — in mini a folder cannot
// expand in place, so a toggle there would be a control that does nothing.
//
// FILE LENGTH (>500): the mini folder flyout is a shared single instance
// declared at this root (never one Popup per row), which is what keeps it
// cheap; the hover bubble is not here at all any more — it is the shell's
// shared QbzTooltip overlay. The flyout is an extraction candidate into
// qml/shell/SidebarFolderFlyout.qml, but a new .qml also needs a build.rs
// entry and an unregistered type fails the WHOLE file at load, so they stay
// here until that glue lands.
// FILE LENGTH (>500): what remains after the mini folder flyout moved to
// shell/SidebarFolderFlyout.qml and the row context menu to
// shell/SidebarRowMenu.qml is ONE surface with one state machine — the three
// sidebar widths. Every block below is a WIDTH-CONDITIONAL variant of the same
// tree (the nav rows, the PLAYLISTS toolbar, the row delegate and the
// scrollbar each read `root.mini`), so splitting them into files would hand
// each half a `mini` property to keep in sync and put the row delegate's
// drop-target arithmetic — which needs `root`'s own window coordinates — a
// file away from the root it measures. The three things that DID come out are
// the three that are self-contained: both overlays and the hover bubble (the
// shell's shared QbzTooltip). All three are shared single instances declared
// at this root, never one per row: the tree can hold hundreds of rows.
//
// Top-level section nav rows (Discover / Library / Local Library / My QBZ)
// replicate SidebarNavRow: 34px rows, radius 6, 16px icons, 13px/w500
@@ -45,14 +50,26 @@
// nothing uses it today (a row that clicks into nothing is a defect, not a
// stub). Its leading glyph goes through shell/NavSectionGlyph.qml, because My
// QBZ can carry a user-supplied icon (SidebarNavRow `raw-icon`).
// The playlist/folder tree below the nav IS live (sidebar_qt.rs: load,
// sort, search, expand/collapse, drag-drop target); folder CREATION,
// the Playlist Manager and the importer have no seam and are disabled in
// the "..." menu.
// The playlist/folder tree below the nav IS live (sidebar_qt.rs: load, sort,
// search, expand/collapse, drag-drop target), and so is everything the "..."
// menu offers: New folder opens QbzFolderEdit's create panel (the small
// name-only create modal, controls/FolderModals.qml), Manage playlists routes
// to the Playlist Manager, Import opens the importer and Refresh rebuilds the
// tree. Import is the only row that dims — it is the only Qobuz-only one, so it
// follows the reference's `enabled: !offline`; Refresh stays lit because its
// bridge call is the offline-safe rebuild (see the Repeater's comment). Nothing
// in this file renders a control that no-ops.
//
// Right-pressing a row opens shell/SidebarRowMenu.qml (edit / hide / mixtape /
// move-to-folder). It is suppressed on the mini rail: Slint anchors that menu
// at `row.width - 210px`, which on a 34px rail row is deeply negative and Qt's
// viewport clamp would drop it at x=8 — not the reference's placement, just
// the clamp.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
@@ -184,33 +201,31 @@ Rectangle {
// Mini folder click: the flyout, not a toggle (the nested rows are hidden,
// so toggling is a control that renders and does nothing — Slint opens the
// popup instead, Sidebar.slint:355-377).
//
// It no longer force-expands the folder on the way in. That used to be
// necessary because the flyout filtered the published `entries`, which
// carry a folder's children ONLY while it is expanded — a persistent side
// effect (the folder stayed open once the sidebar was re-opened) the
// reference does not have. The flyout now asks Rust for a dedicated
// cache-built document instead (SidebarFolderFlyout.qml / §4.7).
function openFolderFlyout(row, entry) {
folderFlyout.folderId = entry.id
folderFlyout.folderName = entry.name
folderFlyout.folderCount = entry.count
// The children are only present in the published entries while the
// folder is EXPANDED. Expand it if it is not — never toggle blindly:
// a folder left open before the sidebar collapsed would be CLOSED by a
// toggle and the flyout would list nothing (and while a search query is
// active every folder is force-expanded, sidebar_qt.rs:299).
if (!entry.expanded) QbzShell.sidebarToggleFolder(entry.id)
var p = row.mapToItem(root, row.width + 6, 0)
folderFlyout.x = p.x
folderFlyout.y = p.y
// The flyout takes the same +6px slot the bubble occupies, and it is a
// Popup (window overlay) so it would paint straight over it. Slint drops
// the tooltip explicitly here too (Sidebar.slint:353).
if (root.tooltip) root.tooltip.hideAll()
folderFlyout.open()
folderFlyout.openFor(entry, row)
}
// Cycling the sidebar while either is open would leave it floating over a
// rail that no longer exists (the bubble self-hides through `root.mini`;
// the flyout needs telling).
// Cycling the sidebar while any of the three is open would leave it
// floating over a rail that no longer exists (the bubble self-hides
// through `root.mini`; the two Popups need telling).
Connections {
target: QbzShell
function onSidebarStateChanged() {
folderFlyout.close()
// The row menu is row-anchored and the rows are about to change
// width — or, going to state 2, stop existing.
rowMenu.close()
// The bubble's anchor is a rail row that is about to move or stop
// existing (SidebarTooltip.slint:14 gates on sidebar-mini for the
// same reason).
@@ -238,157 +253,34 @@ Rectangle {
// TooltipState, state.slint:4791), precisely so a clipped surface — and
// this sidebar clips — can still show a bubble outside its own bounds.
// Folder flyout — SidebarFolderPopup.slint: 230px, 30px header row +
// hairline + up to four 32px rows, 4px inner padding, surface-main /
// radius sm / 1px border-muted.
Popup {
// Folder flyout (mini rail) and the row context menu — ONE shared instance
// each, declared at this root, never one per delegate: the tree can hold
// hundreds of rows. Both are Popups, so their content is reparented to the
// WINDOW overlay and this root's `clip: true` cannot cut them off.
SidebarFolderFlyout {
id: folderFlyout
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 + folderFlyout.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
onPlaylistActivated: function (id) { root.activePlaylistId = id }
}
property string folderId: ""
property string folderName: ""
property int folderCount: 0
// The canonical folder list, parsed once. `foldersJson` is the Playlist
// Manager's property because the manager owns the rich folder record, but
// it is kept fresh independently of the manager's document cache
// (refresh_folders()), so the sidebar can read it before — and without —
// the manager view ever being opened.
readonly property var pmFolders: {
try { return JSON.parse(QbzPlaylistManager.foldersJson) } catch (e) { return [] }
}
// Hidden folders are dropped EVERYWHERE in the sidebar tree, so the row
// menu must not offer them, must not count them toward its search
// threshold and must not let them suppress its empty state. Derived once,
// here, and read three times over there.
readonly property var visibleFolders: root.pmFolders.filter(function (f) {
return f.isHidden !== true
})
// The rows are a BINDING over the published entries, never a snapshot
// read inside the click handler: sidebarToggleFolder republishes
// sidebarJson through the cxx-qt UI queue (shell_bridge.rs::ui ->
// CxxQtThread::queue), so the children land a LATER event-loop turn.
// Reading `root.entries` at click time would open an empty panel.
readonly property var rows: root.entries.filter(function (e) {
return e.kind !== "folder" && e.folderId === folderFlyout.folderId
})
// Height off the folder's own count (known at click time) so the panel
// does not resize when the children land a tick later.
readonly property int visibleRows:
Math.min(Math.max(folderFlyout.folderCount, folderFlyout.rows.length), 4)
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
spacing: 0
// 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: folderFlyout.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).
Item {
width: parent.width
height: folderFlyout.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: folderFlyout.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: {
root.activePlaylistId = fpRow.modelData.id
QbzBridge.openPlaylist(fpRow.modelData.id)
folderFlyout.close()
}
}
}
}
}
}
QbzScrollBar {
visible: folderFlyout.folderCount > 4 && fpFlick.contentHeight > fpFlick.height
target: fpFlick
width: 10
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
SidebarRowMenu {
id: rowMenu
visibleFolders: root.visibleFolders
}
width: QbzShell.sidebarState === 2 ? 0
@@ -604,9 +496,17 @@ Rectangle {
}
}
}
// New playlist (+) — single core call (create_playlist), then
// New playlist (+) — single bridge call (create_playlist), then
// reload the tree. Slint opens a naming modal; the POC creates
// with the default name (POC-NOTE).
//
// NOT offline-gated, and that is deliberate: `crate::create_playlist`
// branches on connectivity and creates a LOCAL playlist while
// offline (the reference's D8 — its create modal locks the
// offline-only toggle ON there). Dimming this row would take the
// ONE way an account-less user makes a playlist away from exactly
// the state they live in. Before that branch existed the button was
// lit and its whole offline effect was a log line.
Rectangle {
width: 22
height: 22
@@ -649,15 +549,22 @@ Rectangle {
}
Popup {
id: sortMenu
// Opens to the RIGHT of the "..." (overlay into the
// content area) — owner request in Sidebar.slint.
x: parent.width + 18
// Slint puts this panel's left edge at `toolbar.width -
// 40px`. Resolved: the open sidebar's content column is
// 240 - 2*16 = 208 wide and the "..." button occupies
// 160…182 of it, so 208 - 40 = 168 is the button's own
// x + 8. This Popup is parented to the BUTTON, not to the
// row, so the 1:1 value is x: 8 — the old `parent.width +
// 18` (= 40) put it 32px too far right.
x: 8
y: 26
width: 200
padding: 6
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceCard
// Matches this root's own ambient treatment
// (Sidebar.slint:920-922).
color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
radius: 8
border.width: 1
border.color: theme.borderSubtle
@@ -727,41 +634,104 @@ Rectangle {
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
// New folder / Manage playlists / Import — DISABLED.
// GAP, not a half-path: this port has no folder
// creation seam (`sidebar_qt.rs` exposes only
// load/sort/search/toggle-folder — no create/delete),
// no Playlist Manager view, and no importer. They used
// to render as live-looking rows (hover highlight +
// pointing-hand cursor) whose click did nothing at
// all; a control that renders and no-ops is a defect,
// so they now read as unavailable: dimmed, no hover
// treatment, no pointer cursor, no MouseArea. Restore
// the interactive form together with the seam.
// New folder / Manage playlists / Import / Refresh —
// all four LIVE (the Refresh row was missing from this
// port entirely and is restored here).
//
// `on` is the enabled flag and it drives everything at
// once: the hover fill, the pointer cursor, the glyph
// tint, the 0.4 dim AND whether the MouseArea exists.
// There is no way to light one row and leave another
// inert without it.
//
// Import is the only row that can be off, and only
// while offline — importing a playlist is Qobuz-only
// work.
//
// REFRESH STAYS LIT OFFLINE (reversed 2026-07-31 on the
// owner's smoke). The earlier decision dimmed it
// because QbzShell.reloadSidebar() called
// crate::reload_sidebar(), which early-returns offline
// — a lit row would have been a no-op, and a control
// that renders and no-ops is a defect. The bridge now
// calls crate::reload_sidebar_including_local()
// instead, which offline re-reads folders, folder
// membership, the hidden set and the LOCAL playlists
// from library.db and republishes (preserving the
// cached Qobuz set). That is a real refresh, and it is
// precisely the tree an account-less user owns — the
// owner hit an empty offline sidebar with no way to
// rebuild it.
//
// Every arm closes the menu FIRST. Slint's PopupWindow
// dismisses on any click, including inside itself,
// which is why its own items never call close(); Qt's
// CloseOnPressOutside does the opposite. This is the
// single most common silent divergence in this seam.
Repeater {
model: [
{ "icon": "folder-plus", "label": QbzSession.tr("New folder", QbzSession.trRev) },
{ "icon": "library-big", "label": QbzSession.tr("Manage playlists", QbzSession.trRev) },
{ "icon": "import", "label": QbzSession.tr("Import", QbzSession.trRev) },
{ "icon": "folder-plus", "label": QbzSession.tr("New folder", QbzSession.trRev),
"act": "newFolder", "on": true },
{ "icon": "library-big", "label": QbzSession.tr("Manage playlists", QbzSession.trRev),
"act": "manage", "on": true },
// LIVE. The importer landed with this seam
// (src/playlist_import_bridge.rs +
// controls/PlaylistImportModal.qml), so contract
// §10 Q2 resolves to "land the importer" and
// there is no `typeof QbzPlaylistImport` guard
// here: the row is a real row, dimmed only
// while offline — the only row that dims.
{ "icon": "import", "label": QbzSession.tr("Import", QbzSession.trRev),
"act": "import", "on": !QbzSession.offline },
{ "icon": "refresh-cw", "label": QbzSession.tr("Refresh", QbzSession.trRev),
"act": "refresh", "on": true },
]
delegate: Rectangle {
id: actRow
required property var modelData
readonly property bool rowEnabled: modelData.on === true
width: parent ? parent.width : 0
height: 30
radius: 5
color: "transparent"
opacity: 0.4
color: (actRow.rowEnabled && actArea.containsMouse)
? theme.surfaceHover : "transparent"
opacity: actRow.rowEnabled ? 1.0 : 0.4
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 8
QbzIcon { name: modelData.icon; width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter; tintName: "muted" }
QbzIcon {
name: actRow.modelData.icon
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: actRow.rowEnabled ? "secondary" : "muted"
}
Text {
width: parent.width - 22
height: parent.height
text: modelData.label
color: theme.textMuted
text: actRow.modelData.label
color: actRow.rowEnabled ? theme.textSecondary : theme.textMuted
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: actArea
anchors.fill: parent
enabled: actRow.rowEnabled
hoverEnabled: actRow.rowEnabled
cursorShape: actRow.rowEnabled
? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
sortMenu.close()
var a = actRow.modelData.act
if (a === "newFolder") QbzFolderEdit.openCreate()
else if (a === "manage") QbzPlaylistManager.navigate()
else if (a === "import") QbzPlaylistImport.open()
else if (a === "refresh") QbzShell.reloadSidebar()
}
}
}
@@ -870,12 +840,18 @@ Rectangle {
: 32
visible: !(root.mini && modelData.indent)
radius: 6
// success-bg + success-border while hot (Theme
// success family, #3fae6a at 10% / 30%).
color: dropHot ? "#3fae6a1a"
// success-bg + success-border while hot. These used
// to be the Slint literals "#3fae6a1a" /
// "#3fae6a4d" copied verbatim, which is a real
// colour bug: Slint's 8-digit form is #RRGGBBAA and
// Qt's is #AARRGGBB, so the drop target rendered
// olive-brown at 25% alpha instead of success green
// at 15% / 35%. The tokens carry the converted
// values (theme.successBg = #263fae6a).
color: dropHot ? theme.successBg
: ((rowArea.containsMouse || isActive) ? theme.surfaceHover : "transparent")
border.width: dropHot ? 1 : 0
border.color: "#3fae6a4d"
border.color: theme.successBorder
Row {
anchors.fill: parent
@@ -967,7 +943,12 @@ Rectangle {
- (isFolder
? 22 + (countLabel.visible
? countLabel.implicitWidth + 8 : 0)
: 0))
: 0)
// The local mark is a sibling AFTER
// this Text, so its width has to come
// out of the name's reserve or the
// glyph lands past the row's clip.
- (modelData.isLocal === true ? 20 : 0))
height: parent.height
text: modelData.name
color: (rowArea.containsMouse || isActive) ? theme.textPrimary : theme.textSecondary
@@ -976,6 +957,18 @@ Rectangle {
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// LOCAL playlist mark. A local playlist has no
// Qobuz side at all — this is how the row says
// so, and it is the only visual difference
// between the two kinds in the list.
QbzIcon {
visible: !root.mini && modelData.isLocal === true
name: "hard-drive"
width: 12
height: 12
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
// Folder count (hover) + chevron. The count is
// hover-FADED, not hidden — opacity does not
// free layout space, so it must stay in the
@@ -1014,16 +1007,39 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// Right-press anywhere on the row opens its
// context menu (Sidebar.slint:389-397). The old
// hover "..." button was removed upstream
// because the scrollbar overlapped it and ate
// the click — do not reintroduce it.
acceptedButtons: Qt.LeftButton | Qt.RightButton
// Mini: the row is an icon, so hovering names
// it (Sidebar.slint:342-349). No-op while open.
onContainsMouseChanged: {
if (containsMouse) root.showMiniTip(plRow, plRow.modelData)
else root.hideMiniTip(plRow.modelData.id)
}
onClicked: {
onClicked: function (mouse) {
// Slint drops the bubble on click so it
// does not linger behind the flyout.
root.hideMiniTip(plRow.modelData.id)
if (mouse.button === Qt.RightButton) {
// SUPPRESSED ON THE RAIL. Slint anchors
// this menu at `row.width - 210px`,
// which on a 34px rail row is deeply
// negative; Qt's viewport clamp would
// silently drop it at x=8, which is not
// the reference's placement, just the
// clamp.
if (root.mini)
return
var lx = plRow.width - rowMenu.menuWidth
if (isFolder)
rowMenu.openForFolder(plRow.modelData, plRow, lx, 28)
else
rowMenu.openForPlaylist(plRow.modelData, plRow, lx, 28)
return
}
if (isFolder) {
// Mini: the nested rows are hidden, so
// a toggle would change NOTHING on
@@ -0,0 +1,230 @@
// 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
}
}
}
}
+367
View File
@@ -0,0 +1,367 @@
// Sidebar row context menu — the port of the PopupWindow declared inside
// SidebarRow (shell/Sidebar.slint:400-545). Right-press any tree row and it
// opens, row-anchored, with one of two arms:
//
// FOLDER Edit folder · Hide from sidebar (exactly two rows —
// there is NO Delete here. Slint declares a delete-folder callback
// and wires it, but no menu item ever fires it: it belonged to a
// hover "..." button that was deleted. Folder deletion lives only
// in the Playlist Manager, so it is not ported as a menu entry.)
//
// PLAYLIST Edit playlist · Hide from sidebar* · Add to Mixtape/Collection*
// · divider* · "Move to folder" caption · folder search (>= 8) ·
// the folder list · Move to root (when filed) · "No folders yet."
// (*) Qobuz only — see below.
//
// Facts that look like bugs and are not:
//
// - LOCALS CAN BE MOVED TO FOLDERS. The move-to-folder section is gated on
// "not a folder row", never on "not a local": one folder holds both kinds,
// and for a user with no Qobuz account that is the whole feature.
// - LOCALS CANNOT BE HIDDEN from here, and cannot be added to a Mixtape.
// `myqbz_add_qt::accepts_item` answers NONE for (Playlist, Local) and the
// core rejects the enqueue outright, so the row is ABSENT rather than
// dimmed — a permanently dead affordance is the defect this port removed
// from the sidebar, not a state to render.
// - The divider is Qobuz-only too, so a local's menu reads "Edit playlist"
// straight into the "Move to folder" caption with no separator.
// - "Move to root" (chevron-left) is this surface's wording; the manager's
// sibling menu says "No folder". Two words for one concept, in two
// surfaces, exactly as the reference has them. Not unified without the
// owner.
// - Folders in this list carry a GENERIC folder glyph. The sidebar never
// receives a folder's icon preset / colour, and the manager's own move menu
// shows no icon at all.
//
// THE CLOSE-POLICY INVERSION, recorded so nobody re-derives it: Slint's
// PopupWindow closes on ANY click, which is why its items barely need
// close(); this one had to opt OUT (close-on-click-outside) so clicking the
// folder-search input would not dismiss it. Qt is the mirror image —
// CloseOnPressOutside never closes on an inside click — so the search input
// works for free, and EVERY row that acts must call close() itself.
//
// Reuse: this inherits controls/QbzContextMenu.qml, which already delivers the
// exact ContextMenu.slint chrome (surface-main, Radius.sm, 1px border-muted,
// 5px vertical padding), the window-overlay parenting a clipping sidebar
// needs, and the 8px viewport clamp. Rejected controls/CardMenu.qml (a fixed
// play/queue/pin/fav set bound to a card document — nothing in common beyond
// "it is a popup") and writing a second popup surface. The rows are a local
// inline component rather than a control, because ContextMenuItem's shape is
// 33/11/16/10/15/13 full-bleed and no shipped control is that.
//
// The one chrome override: QbzContextMenu pads all four sides by 5, while
// ContextMenu.slint pads only top and bottom — its rows are full-bleed. Hence
// leftPadding/rightPadding 0 below.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
QbzContextMenu {
id: root
// Sidebar.slint:402 — `width: 210px`, not QbzContextMenu's 196 default.
menuWidth: 210
leftPadding: 0
rightPadding: 0
topPadding: 5
bottomPadding: 5
/// The clicked SidebarEntry: {kind, id, name, count, indent, folderId,
/// covers, isLocal?}. Null before the first open.
property var entry: null
/// foldersJson MINUS the hidden folders, derived ONCE by the sidebar and
/// passed in. Every folder-dependent decision in this file reads THIS, not
/// the raw folder list: the sidebar tree drops hidden folders everywhere
/// else, so offering one here — or counting one toward the >= 8 search
/// threshold, or letting one suppress "No folders yet." — would show a
/// search box over a three-row list and move a playlist somewhere it
/// cannot be seen.
property var visibleFolders: []
/// Local substring filter over `visibleFolders`. The reference runs this
/// in Rust (`search_menu_folders`) for ONE reason it states in its own
/// source: Slint strings have no `contains`. JavaScript has `indexOf`, so
/// the round trip per keystroke is dropped. Deliberate; do not re-add it
/// "for parity".
property string folderQuery: ""
readonly property bool isFolder: root.entry !== null && root.entry.kind === "folder"
readonly property bool isLocal: root.entry !== null && root.entry.isLocal === true
readonly property string entryId: root.entry !== null ? String(root.entry.id) : ""
readonly property string entryFolderId:
(root.entry !== null && root.entry.folderId) ? String(root.entry.folderId) : ""
readonly property var menuFolders: {
var q = root.folderQuery.trim().toLowerCase()
if (q === "")
return root.visibleFolders
return root.visibleFolders.filter(function (f) {
return String(f.name).toLowerCase().indexOf(q) !== -1
})
}
// Height source for the list. The CURRENT folder's row is excluded in
// markup (where Slint has it), so sizing off `menuFolders.length` would
// leave one row of dead space — the reference's own bug, not ported.
readonly property int folderRowCount: root.menuFolders.filter(function (f) {
return String(f.id) !== root.entryFolderId
}).length
/// Open on a PLAYLIST row. `anchorItem` is the row; `localX`/`localY` are
/// row-local coordinates, so the sidebar reproduces Slint's row-anchored
/// `x: root.width - 210px; y: 28px` rather than opening at the pointer
/// (this menu is row-anchored in the reference, unlike the TrackRow menus).
function openForPlaylist(e, anchorItem, localX, localY) {
root._openWith(e, anchorItem, localX, localY)
}
/// Open on a FOLDER row. Same placement; the content arms switch on
/// `entry.kind`.
function openForFolder(e, anchorItem, localX, localY) {
root._openWith(e, anchorItem, localX, localY)
}
function _openWith(e, anchorItem, localX, localY) {
root.entry = e
// Slint clears the folder query on EVERY right-press, folder rows
// included, before showing the menu.
root.folderQuery = ""
folderSearch.clearSearch()
root.openAtCursor(anchorItem, localX, localY)
}
// Declared BEFORE the inline component that reads it, matching
// NavFlyout.qml's shipped shape. A QML file has no lexical scope across
// files, so QbzContextMenu's own `theme` is not reachable from here.
QbzTheme { id: theme }
/// One 33px row — ContextMenuItem.slint, full-bleed, no radius.
component MenuRow: Rectangle {
id: mr
property string icon: ""
property string label: ""
signal activated()
width: parent ? parent.width : 0
height: 33
color: mrArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 11
anchors.rightMargin: 16
spacing: 10
QbzIcon {
name: mr.icon
width: 15
height: 15
anchors.verticalCenter: parent.verticalCenter
tintName: mrArea.containsMouse ? "textPrimary" : "secondary"
}
Text {
width: parent.width - 25
height: parent.height
text: mr.label
color: mrArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: mrArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: mr.activated()
}
}
// ---- Folder arm ------------------------------------------------------
MenuRow {
visible: root.isFolder
icon: "pen-line"
label: QbzSession.tr("Edit folder", QbzSession.trRev)
onActivated: {
root.close()
QbzFolderEdit.openEditor(root.entryId)
}
}
MenuRow {
visible: root.isFolder
icon: "eye-off"
label: QbzSession.tr("Hide from sidebar", QbzSession.trRev)
onActivated: {
root.close()
// The folder editor owns folder visibility, and its write
// republishes foldersJson — otherwise a folder hidden from here
// would stay in every other playlist's move-to-folder list.
QbzFolderEdit.setHidden(root.entryId, true)
}
}
// ---- Playlist arm ----------------------------------------------------
MenuRow {
visible: !root.isFolder
icon: "pen-line"
label: QbzSession.tr("Edit playlist", QbzSession.trRev)
onActivated: {
root.close()
// A `local:` id survives untouched — the editor seeds itself from
// the local DB for those and works offline.
QbzPlaylistEdit.open(root.entryId)
}
}
MenuRow {
// Locals are hidden from the Playlist Manager, never from here: the
// reference's own hide path parses a u64 and bails on a local id.
visible: !root.isFolder && !root.isLocal
icon: "eye-off"
label: QbzSession.tr("Hide from sidebar", QbzSession.trRev)
onActivated: {
root.close()
// One-way door FROM the sidebar; the manager's All/Visible/Hidden
// filter is the only undo surface in the app, which is why this
// row could not ship before that filter existed.
QbzPlaylistManager.toggleHidden(root.entryId)
}
}
MenuRow {
visible: !root.isFolder && !root.isLocal
icon: "cassette-tape"
label: QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev)
onActivated: {
root.close()
// Built HERE from the SidebarEntry the row already carries — the
// mixtape payload is assembled at the call site everywhere in this
// port, and the sidebar must not grow a Rust helper for another
// domain. `source` is always "qobuz" because the row is absent on
// a local entry.
//
// `trackCount` is deliberately OMITTED, which is the one place
// this file departs from the contract's literal snippet. A
// SidebarEntry's `count` is populated for FOLDER rows only —
// `entry_for` / `local_entry_for` both hardcode `count: 0`
// (sidebar_qt.rs:379, :390), and the field is the folder's member
// count, not a track count. `myqbz_add_qt` PERSISTS this value
// into the collection row (`add_item_with(..., it.track_count,
// ...)`), so sending 0 would write a permanent wrong number;
// absent means NULL, which is the truthful "unknown". It is
// Option<i32> on the Rust side and every other optional key here
// is already omitted the same way.
var e = root.entry
QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "playlist",
"source": "qobuz",
"sourceItemId": String(e.id),
"title": String(e.name),
"artworkUrl": (e.covers && e.covers.length > 0) ? String(e.covers[0]) : ""
}]))
}
}
// Divider before the move-to-folder section — Qobuz only.
Item {
visible: !root.isFolder && !root.isLocal
width: parent ? parent.width : 0
height: 7
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
height: 1
color: theme.borderSubtle
}
}
// ---- Move to folder --------------------------------------------------
// Caption block: padding L 11 / R 16 / bottom 3, 18px text row.
Item {
visible: !root.isFolder
width: parent ? parent.width : 0
height: 21
Text {
x: 11
width: parent.width - 27
height: 18
text: QbzSession.tr("Move to folder", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
// Search block: padding L 11 / R 16 / bottom 4, 26px field. Appears only
// once the list is long enough to warrant it (the reference's threshold,
// counted over the VISIBLE folders).
Item {
visible: !root.isFolder && root.visibleFolders.length >= 8
width: parent ? parent.width : 0
height: 30
QbzLineEdit {
id: folderSearch
x: 11
width: parent.width - 27
height: 26
searchMode: true
placeholder: QbzSession.tr("Search folders", QbzSession.trRev)
onEdited: function (v) { root.folderQuery = v }
}
}
// The folder list. A ListView at a fixed clip height, not a Repeater in a
// Flickable: only ~5 rows are VISIBLE, but the model is every folder the
// user has.
ListView {
visible: !root.isFolder
width: parent ? parent.width : 0
height: Math.min(root.folderRowCount * 33, 165)
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.menuFolders
delegate: MenuRow {
required property var modelData
// A ListView does NOT size its delegates: `parent.width` here is
// the contentItem's, which is not the view's.
width: ListView.view ? ListView.view.width : 0
// The playlist's CURRENT folder is excluded in markup, exactly
// where Slint has it.
visible: String(modelData.id) !== root.entryFolderId
height: visible ? 33 : 0
icon: "folder"
label: String(modelData.name)
onActivated: {
root.close()
QbzPlaylistManager.moveToFolder(root.entryId, String(modelData.id))
}
}
}
// Move to root — only when the playlist is currently filed. It comes
// AFTER the scrolling list, and "No folders yet." after that; both sit
// outside the scroll area. Reference ordering, kept.
MenuRow {
visible: !root.isFolder && root.entryFolderId !== ""
icon: "chevron-left"
label: QbzSession.tr("Move to root", QbzSession.trRev)
onActivated: {
root.close()
// "" is ROOT.
QbzPlaylistManager.moveToFolder(root.entryId, "")
}
}
// Empty state: padding L 11 / R 16 / bottom 4, 22px text row.
Item {
visible: !root.isFolder && root.visibleFolders.length === 0
width: parent ? parent.width : 0
height: 26
Text {
x: 11
width: parent.width - 27
height: 22
text: QbzSession.tr("No folders yet.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
+12
View File
@@ -14,6 +14,18 @@ QbzContextMenu {
id: root
menuWidth: 196
// The theme tokens. Declared HERE even though the BASE type
// (controls/QbzContextMenu.qml) already has a `QbzTheme { id: theme }`:
// a QML id belongs to the document that declares it, and a derived
// document is a DIFFERENT scope — the base's context is created as a
// child of this one, never as its parent, so `theme` was resolving to
// nothing here and every binding below threw a silent ReferenceError
// (leaving the row Rectangles at their default WHITE fill). The four
// sibling menus built on the same base — CardMenu, AudioSettingsMenu,
// PmFolderMenu, SidebarRowMenu — all declare their own; this file was
// the lone outlier.
QbzTheme { id: theme }
Repeater {
model: [
{ "label": QbzSession.tr("New", QbzSession.trRev), "icon": "panel-left", "mode": 0 },