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:
115 files changed
+20778
-1637
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user