feat(qt): bring back the New playlist modal, and delete the shortcut
The sidebar "+" called create_playlist(), which made a playlist named literally "New Playlist" and navigated to it. Four of the modal's five fields had no other door, and one had NO door at all: name hardcoded reachable in Edit playlist description always empty reachable in Edit playlist folder always root reachable in the sidebar row menu public always false NOWHERE offline-only from connectivity NOWHERE for an online user That last row is why this counts as a regression rather than a rough edge. offline_only decides whether the playlist is a local_playlists row (local:<uuid>, library.db, no Qobuz account, never uploaded) or a Qobuz entity, and the shortcut derived it from connectivity alone — so someone with a Qobuz session could not create a local playlist without pulling the network cable. Ported against CreatePlaylistModal.slint plus on_create_playlist (main.rs:21107) and on_submit (:21496). The submit branches where the reference branches, and a creation forced local by being offline KEEPS the flag, as the reference's own note says, so Edit can unmark it later to enable "Upload to Qobuz". Two calls made here: - Create rides the QbzPlaylistEdit singleton rather than getting its own, where the reference splits them. Same domain — a playlist's own metadata — with two documents that never interact, and a second QObject is a second boot() to forget; that bridge's header already documents a missing boot() swallowing publishes silently and forever. - The folder picker hides on the offline-only arm. folders_qt::move_playlist takes a u64 and a local:<uuid> has none, so the reference shows the control and discards the value. The shortcut is deleted rather than left beside the modal: a second creation path that skips it is the fork the port rule forbids. All thirteen strings were already in the catalogues. cc
This commit is contained in:
8 files changed
+883
-85
No files matched your search
@@ -414,6 +414,7 @@ fn main() {
|
||||
"qml/controls/FolderModals.qml",
|
||||
"qml/controls/GroupHeader.qml",
|
||||
"qml/controls/MyQbzModals.qml",
|
||||
"qml/controls/PlaylistCreateModal.qml",
|
||||
"qml/controls/PlaylistEditModal.qml",
|
||||
"qml/controls/PlaylistImportModal.qml",
|
||||
"qml/controls/PlaylistPickerModal.qml",
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
// PlaylistCreateModal — "New playlist" (primitives/CreatePlaylistModal.slint).
|
||||
//
|
||||
// WHAT THIS REPLACES. The sidebar "+" used to call `QbzShell.createPlaylist()`,
|
||||
// which created a playlist literally named "New Playlist" and navigated to it.
|
||||
// Four of this panel's five fields had no other door — and one of them, the
|
||||
// offline-only toggle, had no door AT ALL for an online user, so someone with a
|
||||
// Qobuz session could not create a LOCAL playlist without pulling the network
|
||||
// cable. `playlist_create_qt`'s header has the table.
|
||||
//
|
||||
// Mounted ONCE in qml/shell/AppShell.qml beside PlaylistEditModal, with no `z`
|
||||
// — that file's convention is DECLARATION ORDER, and ADR-009's ">= 3000" is
|
||||
// satisfied structurally.
|
||||
//
|
||||
// ── SHAPE, from the reference ─────────────────────────────────────────────
|
||||
// Name (LineEdit, Enter submits) · Description (multiline, optional) ·
|
||||
// Folder (only when the user HAS folders) · "Make playlist public" ·
|
||||
// "Offline-only playlist" (+ hint while locked) · Cancel / Create.
|
||||
//
|
||||
// ── THE DRAFTS LIVE HERE ──────────────────────────────────────────────────
|
||||
// Name, description, folder index, public and offline-only are QML-LOCAL and
|
||||
// reach Rust only as arguments to `createSubmit(...)` — MyQbzModals.qml's
|
||||
// standing rule, the same one PlaylistEditModal.qml follows. Rust owns only
|
||||
// `busy`, the offline lock and the folder list.
|
||||
//
|
||||
// ── THE FOLDER IS SENT AS AN ID, NOT AN INDEX ─────────────────────────────
|
||||
// The reference keeps two parallel arrays (`folder-options` / `folder-ids`) and
|
||||
// resolves the id from the index at submit time. Here the document is a list of
|
||||
// `{id, name}` objects and the id travels: parallel arrays that must stay in
|
||||
// step across a process boundary are a class of bug this port does not need,
|
||||
// and index 0's EMPTY id is what "no folder" means — never the index itself.
|
||||
//
|
||||
// ── THE FOLDER PICKER'S VISIBILITY IS THE REFERENCE'S ─────────────────────
|
||||
// `folder-options.length > 1` (CreatePlaylistModal.slint:118): the row is
|
||||
// absent when the only option is "No folder". Plus one thing the reference does
|
||||
// not need: it also hides on the OFFLINE-ONLY arm, because `folders_qt
|
||||
// ::move_playlist` keys on a Qobuz `u64` and a `local:<uuid>` has none. Showing
|
||||
// a picker whose choice is silently dropped is the dead-affordance class the
|
||||
// port refuses; the reference never hit it because its own local arm ignores
|
||||
// the field just as quietly.
|
||||
//
|
||||
// ── PUBLIC DIMS UNDER OFFLINE-ONLY ────────────────────────────────────────
|
||||
// A local playlist has nothing to be public ON (reference :138-146). It dims to
|
||||
// 0.4 and stops taking clicks — it is not merely ignored at submit.
|
||||
//
|
||||
// ── `busy` HOLDS THE MODAL OPEN ───────────────────────────────────────────
|
||||
// Rust closes it, and only after the write landed. `closeCreate()` is REFUSED
|
||||
// while busy, so the close X and the scrim go inert for that window rather than
|
||||
// rendering and no-opping.
|
||||
//
|
||||
// ── ESCAPE IS A WRAPPING FocusScope, NOT A SIBLING ONE ────────────────────
|
||||
// The name field takes active focus and QML propagates an unhandled key up the
|
||||
// FOCUSED item's parent chain, so a sibling scope would be dead the moment the
|
||||
// field is focused (the FolderModals.qml note).
|
||||
//
|
||||
// ── ENTER ─────────────────────────────────────────────────────────────────
|
||||
// Through QbzLineEdit's `accepted`, NEVER `committed`: committed also fires on
|
||||
// focus-out, and closing the modal blurs the field, which would create twice.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
||||
|
||||
// Guarded parse: a raw JSON.parse in a binding throws on the pre-publish
|
||||
// frame (PlaylistView.qml:55-61).
|
||||
readonly property var doc: {
|
||||
try { return JSON.parse(QbzPlaylistEdit.createJson) } catch (e) { return ({}) }
|
||||
}
|
||||
|
||||
readonly property bool createOpen: root.doc.open === true
|
||||
readonly property bool busy: root.doc.busy === true
|
||||
readonly property bool offlineLocked: root.doc.offlineLocked === true
|
||||
readonly property var folders: root.doc.folders || []
|
||||
|
||||
// --- Drafts -----------------------------------------------------------
|
||||
property string draftName: ""
|
||||
property string draftDescription: ""
|
||||
property int draftFolderIndex: 0
|
||||
property bool draftIsPublic: false
|
||||
property bool draftOfflineOnly: false
|
||||
|
||||
readonly property bool canCreate: root.draftName.trim() !== "" && !root.busy
|
||||
// The reference's `folder-options.length > 1`, plus the local-arm gate.
|
||||
readonly property bool folderPickerVisible:
|
||||
root.folders.length > 1 && !root.draftOfflineOnly
|
||||
|
||||
visible: root.createOpen
|
||||
enabled: root.visible
|
||||
|
||||
// Seeds are consumed ONCE per open. A plain republish (the `busy` flip on
|
||||
// submit) must NOT reseed — it would throw away what the user typed.
|
||||
onCreateOpenChanged: {
|
||||
if (root.createOpen) {
|
||||
root.draftName = ""
|
||||
root.draftDescription = ""
|
||||
root.draftFolderIndex = 0
|
||||
root.draftIsPublic = false
|
||||
// Offline forces it ON and locks it (reference :184-216).
|
||||
root.draftOfflineOnly = root.offlineLocked
|
||||
scope.forceActiveFocus()
|
||||
nameField.focusField()
|
||||
} else {
|
||||
root._restoreShellFocus()
|
||||
}
|
||||
}
|
||||
|
||||
// §1.4.3 (2026-08-03 hotkeys-port contract): the FocusScope grabs focus on
|
||||
// open and Qt strands it on the now-invisible scope at close, which kills
|
||||
// the AppShell key dispatcher until the next click. Restore the shell root.
|
||||
function _restoreShellFocus() {
|
||||
var p = root
|
||||
while (p.parent) {
|
||||
if (p.parent.isQbzShellRoot === true) {
|
||||
p.parent.forceActiveFocus()
|
||||
return
|
||||
}
|
||||
p = p.parent
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!root.canCreate)
|
||||
return
|
||||
// "" is ROOT. The id comes from the row, never from the index — and on
|
||||
// the local arm the picker is not even rendered, so it is "" there by
|
||||
// construction rather than by a second rule.
|
||||
var fid = ""
|
||||
if (root.folderPickerVisible
|
||||
&& root.draftFolderIndex > 0
|
||||
&& root.draftFolderIndex < root.folders.length)
|
||||
fid = String(root.folders[root.draftFolderIndex].id || "")
|
||||
QbzPlaylistEdit.createSubmit(root.draftName, root.draftDescription,
|
||||
fid, root.draftIsPublic,
|
||||
root.draftOfflineOnly)
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
id: scope
|
||||
anchors.fill: parent
|
||||
visible: root.createOpen
|
||||
enabled: root.createOpen
|
||||
|
||||
// Taken HERE rather than only in the open handler: an invisible item
|
||||
// cannot hold active focus, and a handler on the document property is
|
||||
// not ordered against the `visible` binding it shares a frame with.
|
||||
onVisibleChanged: if (scope.visible) scope.forceActiveFocus()
|
||||
|
||||
Keys.onEscapePressed: function (event) {
|
||||
QbzPlaylistEdit.closeCreate()
|
||||
event.accepted = true
|
||||
}
|
||||
|
||||
// `radius` is load-bearing: this Item fills AppShell's ROUNDED content
|
||||
// frame and Qt Quick's clip is a rectangular scissor, so an opaque
|
||||
// full-bleed child paints into the frame's four bezel corners.
|
||||
// #bf000000 is Slint's #000000bf CONVERTED — Slint is #RRGGBBAA, Qt is
|
||||
// #AARRGGBB.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: theme.radiusMd
|
||||
color: "#bf000000"
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: QbzPlaylistEdit.closeCreate()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: Math.min(root.width - 80, 420)
|
||||
height: col.implicitHeight + 40
|
||||
x: Math.round((root.width - width) / 2)
|
||||
y: Math.round((root.height - height) / 2)
|
||||
radius: theme.radiusMd
|
||||
color: theme.surfaceCard
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
|
||||
// FIRST child: swallows clicks so they never reach the scrim.
|
||||
MouseArea { anchors.fill: parent }
|
||||
|
||||
Column {
|
||||
id: col
|
||||
x: 20
|
||||
y: 20
|
||||
width: parent.width - 40
|
||||
spacing: 16
|
||||
|
||||
// ================= Title + close X ======================
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 28
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Math.max(0, parent.width - 28)
|
||||
text: root.t("New playlist")
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightSemibold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Item {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 28
|
||||
height: 28
|
||||
opacity: root.busy ? 0.5 : 1.0
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
width: 17
|
||||
height: 17
|
||||
name: "x"
|
||||
tintName: closeArea.containsMouse ? "textPrimary" : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: closeArea
|
||||
anchors.fill: parent
|
||||
// `closeCreate()` is refused while the write is in
|
||||
// flight, so the affordance goes with it rather
|
||||
// than lying.
|
||||
enabled: !root.busy
|
||||
hoverEnabled: !root.busy
|
||||
cursorShape: root.busy ? Qt.ArrowCursor
|
||||
: Qt.PointingHandCursor
|
||||
onClicked: QbzPlaylistEdit.closeCreate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================= Name =================================
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
Text {
|
||||
text: root.t("Name")
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightMedium
|
||||
}
|
||||
QbzLineEdit {
|
||||
id: nameField
|
||||
width: parent.width
|
||||
text: root.draftName
|
||||
placeholder: root.t("My Playlist")
|
||||
enabled: !root.busy
|
||||
onEdited: function (value) { root.draftName = value }
|
||||
onAccepted: function (value) {
|
||||
root.draftName = value
|
||||
root.submit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================= Description ==========================
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
Text {
|
||||
text: root.t("Description (optional)")
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightMedium
|
||||
}
|
||||
QbzTextArea {
|
||||
width: parent.width
|
||||
height: 80
|
||||
text: root.draftDescription
|
||||
enabled: !root.busy
|
||||
onEdited: function (value) { root.draftDescription = value }
|
||||
}
|
||||
}
|
||||
|
||||
// ================= Folder ===============================
|
||||
// A Column skips invisible children, so this leaves no gap
|
||||
// when the user has no folders (or is on the local arm).
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
visible: root.folderPickerVisible
|
||||
Text {
|
||||
text: root.t("Folder")
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightMedium
|
||||
}
|
||||
QbzSelect {
|
||||
width: parent.width
|
||||
popupWidth: parent.width
|
||||
enabled: !root.busy
|
||||
// QbzSelect takes plain labels; the ids stay on
|
||||
// root.folders and are resolved by INDEX at submit.
|
||||
options: root.folders.map(function (f) { return String(f.name) })
|
||||
currentIndex: root.draftFolderIndex
|
||||
onSelected: function (i) { root.draftFolderIndex = i }
|
||||
}
|
||||
}
|
||||
|
||||
// ================= Public ===============================
|
||||
// Meaningless for a local playlist, so it dims AND locks while
|
||||
// offline-only is on (reference :138-146).
|
||||
//
|
||||
// An Item with a FIXED height, not a Row: a Row's height comes
|
||||
// from its tallest child, so anchoring a child to
|
||||
// `parent.verticalCenter` inside one is a binding loop.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 20
|
||||
opacity: root.draftOfflineOnly ? 0.4 : 1.0
|
||||
Item {
|
||||
id: publicHit
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 20
|
||||
height: 20
|
||||
QbzCheckbox {
|
||||
anchors.centerIn: parent
|
||||
checked: root.draftIsPublic
|
||||
enabled: !root.busy && !root.draftOfflineOnly
|
||||
// QbzCheckbox never self-flips; the owner of the
|
||||
// state does.
|
||||
onToggled: root.draftIsPublic = !root.draftIsPublic
|
||||
}
|
||||
}
|
||||
Text {
|
||||
anchors.left: publicHit.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.t("Make playlist public")
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 15
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
// ================= Offline-only =========================
|
||||
// D8: creates a LOCAL playlist (library.db, id
|
||||
// `local:<uuid>`) that never reaches Qobuz. Forced ON and
|
||||
// LOCKED while the app is offline, with the hint under it.
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 6
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 20
|
||||
opacity: root.offlineLocked ? 0.7 : 1.0
|
||||
Item {
|
||||
id: offlineHit
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 20
|
||||
height: 20
|
||||
QbzCheckbox {
|
||||
anchors.centerIn: parent
|
||||
checked: root.draftOfflineOnly
|
||||
enabled: !root.busy && !root.offlineLocked
|
||||
onToggled: root.draftOfflineOnly = !root.draftOfflineOnly
|
||||
}
|
||||
}
|
||||
Text {
|
||||
anchors.left: offlineHit.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.t("Offline-only playlist")
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 15
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
// Hint indented to the label's left edge (20px checkbox +
|
||||
// 10px spacing), so it reads as the row's description.
|
||||
Text {
|
||||
visible: root.offlineLocked
|
||||
x: 30
|
||||
width: parent.width - 30
|
||||
text: root.t("Created locally — you're offline")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
// ================= Footer ===============================
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 36
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 10
|
||||
|
||||
// Cancel — the reference's neutral button: an
|
||||
// elevated fill that lifts to surface-hover.
|
||||
Rectangle {
|
||||
width: cancelLabel.implicitWidth + 28
|
||||
height: 36
|
||||
radius: theme.radiusSm
|
||||
color: (cancelArea.containsMouse && !root.busy)
|
||||
? theme.surfaceHover : theme.surfaceElevated
|
||||
opacity: root.busy ? 0.5 : 1.0
|
||||
Text {
|
||||
id: cancelLabel
|
||||
anchors.centerIn: parent
|
||||
text: root.t("Cancel")
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 15
|
||||
}
|
||||
MouseArea {
|
||||
id: cancelArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: !root.busy
|
||||
cursorShape: root.busy ? Qt.ArrowCursor
|
||||
: Qt.PointingHandCursor
|
||||
onClicked: QbzPlaylistEdit.closeCreate()
|
||||
}
|
||||
}
|
||||
|
||||
// Create. The reference's `#ffffffd6` hover on an
|
||||
// accent fill and its hardcoded `#ffffff` label are
|
||||
// NOT copied — both wash out on the light palettes,
|
||||
// which is the same call PlaylistEditModal made.
|
||||
QbzPrimaryButton {
|
||||
label: root.busy ? root.t("Creating...") : root.t("Create")
|
||||
btnHeight: 36
|
||||
labelSize: 15
|
||||
btnEnabled: root.canCreate
|
||||
onClicked: root.submit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -760,6 +760,16 @@ Rectangle {
|
||||
PlaylistEditModal {
|
||||
anchors.fill: parent
|
||||
}
|
||||
// PlaylistCreateModal — "New playlist" (name · description · folder ·
|
||||
// public · offline-only), raised by the sidebar's "+" and, like its
|
||||
// neighbour, self-gating on its own document (QbzPlaylistEdit.createJson).
|
||||
// It rides the editor's singleton because it is the same domain — a
|
||||
// playlist's own metadata — with a document that never interacts with the
|
||||
// editor's. It replaces the POC shortcut that created a playlist named
|
||||
// "New Playlist" with no way to set any of the five.
|
||||
PlaylistCreateModal {
|
||||
anchors.fill: parent
|
||||
}
|
||||
// The hotkeys pair (2026-08-03 hotkeys-port contract §4.4/§4.5, block
|
||||
// B3): the read-only cheatsheet (`?` / the HeaderBar menu row) and the
|
||||
// editable customize editor. Same declaration-order convention as their
|
||||
|
||||
@@ -649,17 +649,22 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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).
|
||||
// New playlist (+) — opens controls/PlaylistCreateModal.qml, the
|
||||
// port of primitives/CreatePlaylistModal.slint.
|
||||
//
|
||||
// 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
|
||||
// IT USED TO BE A ONE-CALL SHORTCUT (`QbzShell.createPlaylist()`)
|
||||
// that created a playlist named "New Playlist" and navigated to
|
||||
// it. That shortcut had no way to set the name, the description,
|
||||
// the folder or the public flag — and, the reason it counts as a
|
||||
// regression rather than a rough edge, no way to set OFFLINE-ONLY.
|
||||
// It derived that from connectivity, so an online user could not
|
||||
// create a local playlist at all.
|
||||
//
|
||||
// NOT offline-gated, and that is deliberate: the modal's own D8
|
||||
// arm locks the offline-only toggle ON while the app is offline
|
||||
// and creates a LOCAL playlist. 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.
|
||||
// the state they live in.
|
||||
Rectangle {
|
||||
width: 22
|
||||
height: 22
|
||||
@@ -677,7 +682,7 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzShell.createPlaylist()
|
||||
onClicked: QbzPlaylistEdit.openCreate()
|
||||
}
|
||||
}
|
||||
// Sort / more (...) menu.
|
||||
|
||||
@@ -273,6 +273,7 @@ mod playlist_manager_rows;
|
||||
// offline-only · delete), driven from the manager's three delegates, the
|
||||
// sidebar row menu and the playlist detail header. Plain module — it declares
|
||||
// no #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files.
|
||||
mod playlist_create_qt;
|
||||
mod playlist_edit_qt;
|
||||
mod playlist_qt;
|
||||
// The replacement-search controller (2026-08-17 unavailable-tracks contract
|
||||
@@ -1210,72 +1211,6 @@ pub(crate) fn load_release_section(artist_id: String, release_type: String, offs
|
||||
});
|
||||
}
|
||||
|
||||
/// Sidebar "+" — create an empty playlist with the default name, then
|
||||
/// reload the tree (the Slint flow opens a naming modal — POC-NOTE).
|
||||
///
|
||||
/// OFFLINE it creates a LOCAL playlist instead, which is the reference's D8
|
||||
/// rule collapsed onto this port's no-modal shortcut: `on_create_playlist`
|
||||
/// (qbz/src/main.rs:21109) opens the create modal with `offline_only` set ON
|
||||
/// **and LOCKED** while offline, so creation there always produces a local
|
||||
/// playlist. Without this arm the "+" was a fully lit, pointer-cursor button
|
||||
/// whose entire offline effect was `log::error!` — the dead-control class the
|
||||
/// owner's standing rule forbids, on the ONE surface a user with no Qobuz
|
||||
/// account owns. `offline_only = true` matches the picker's own offline create
|
||||
/// (`playlist_picker_qt.rs:555-559`): the playlist is never offered for upload
|
||||
/// and never reaches a QConnect push.
|
||||
pub(crate) fn create_playlist() {
|
||||
if offline_fwd::engine().status().is_offline() {
|
||||
// The SAME default name the online arm uses — one msgid, one label,
|
||||
// whichever door the user came through.
|
||||
let name = qbz_i18n::t("New Playlist");
|
||||
spawn(async move {
|
||||
let created = tokio::task::spawn_blocking(move || {
|
||||
local_playlist_qt::create_blocking(&name, None, true)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
match created {
|
||||
Some(new_id) => {
|
||||
log::info!("[qbz-qt] local playlist created offline: {new_id}");
|
||||
// The offline-safe verb — `reload_sidebar` early-returns
|
||||
// here, which is exactly this branch.
|
||||
reload_sidebar_including_local();
|
||||
// Land on it, like the online arm does. `open_playlist`
|
||||
// routes a `local:` id to the local loader and does NOT
|
||||
// offline-gate it.
|
||||
open_playlist(new_id);
|
||||
}
|
||||
None => {
|
||||
log::error!("[qbz-qt] offline local playlist create failed");
|
||||
toast_qt::error(qbz_i18n::t("Couldn't create the playlist"));
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
let runtime = app();
|
||||
spawn(async move {
|
||||
match runtime
|
||||
.core()
|
||||
.create_playlist(&qbz_i18n::t("New Playlist"), None, false)
|
||||
.await
|
||||
{
|
||||
Ok(p) => {
|
||||
log::info!("[qbz-qt] playlist created: {} ({})", p.name, p.id);
|
||||
sidebar_qt::load(&runtime).await;
|
||||
publish_sidebar();
|
||||
// Open the new playlist (the Slint lands on it after the
|
||||
// naming modal); the user-playlists endpoint lags the
|
||||
// write, so the tree may not show it yet — the detail view
|
||||
// fetches by id and is correct regardless.
|
||||
open_playlist(p.id.to_string());
|
||||
}
|
||||
Err(e) => log::error!("[qbz-qt] create playlist failed: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// AlbumCard ⋯ menu: Play next / Add to queue.
|
||||
pub(crate) fn enqueue_album(album_id: String, mode: String) {
|
||||
let runtime = app();
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
//! The "New playlist" modal — controller half.
|
||||
//!
|
||||
//! PORT OF `primitives/CreatePlaylistModal.slint` + `SidebarActions
|
||||
//! ::on_create_playlist` (`qbz/src/main.rs:21107`) + `CreatePlaylistActions
|
||||
//! ::on_submit` (`:21496`). It replaces this port's POC shortcut, which was
|
||||
//! `crate::create_playlist()`: one bridge call that created a playlist named
|
||||
//! "New Playlist" and navigated to it.
|
||||
//!
|
||||
//! WHAT THE SHORTCUT COST, and why this is a regression and not a nicety —
|
||||
//! four of the modal's five fields had NO other door in the app:
|
||||
//!
|
||||
//! | field | the shortcut | reachable elsewhere? |
|
||||
//! |---|---|---|
|
||||
//! | name | hardcoded "New Playlist" | yes, Edit playlist |
|
||||
//! | description | always empty | yes, Edit playlist |
|
||||
//! | folder | always root | yes, the sidebar row menu / the manager |
|
||||
//! | public | always `false` | **NO** |
|
||||
//! | offline-only | forced by connectivity | **NO** for an ONLINE user |
|
||||
//!
|
||||
//! The last row is the one that matters and is the reason the owner flagged
|
||||
//! this: an online user could not create a LOCAL playlist at all. `offline_only`
|
||||
//! decides whether the playlist is a `local_playlists` row (id `local:<uuid>`,
|
||||
//! lives in library.db, works with no Qobuz account, never uploaded) or a Qobuz
|
||||
//! entity — and the shortcut derived it from connectivity alone. Someone with a
|
||||
//! Qobuz session who wants a private local playlist for their own files had to
|
||||
//! pull the network cable. That is the D8 opt-in, and it only exists in the
|
||||
//! modal.
|
||||
//!
|
||||
//! # Two arms, and the offline one is not a fallback
|
||||
//!
|
||||
//! `submit` branches exactly where the reference does (`main.rs:21525`):
|
||||
//!
|
||||
//! - `offline_only || the app is offline` -> `local_playlist_qt::create_blocking`
|
||||
//! (a pure DB write, so it works with no session at all). The forced-by-being-
|
||||
//! offline case KEEPS the flag, because the reference says so: it can be
|
||||
//! unmarked later in Edit to enable "Upload to Qobuz".
|
||||
//! - otherwise -> `core().create_playlist()`, then — and only then — the folder
|
||||
//! assignment, which is a LOCAL db write keyed on the new Qobuz id.
|
||||
//!
|
||||
//! The folder dropdown is offered on BOTH arms even though the assignment is
|
||||
//! only wired for the Qobuz one. That is not an oversight: `folders_qt
|
||||
//! ::move_playlist` takes a `u64`, and a `local:` id has none. The picker hides
|
||||
//! itself when the choice cannot be honoured — see `submit`.
|
||||
//!
|
||||
//! # `busy` HOLDS THE MODAL OPEN (D22)
|
||||
//!
|
||||
//! Same discipline as `playlist_edit_qt`, and deliberately STRICTER than the
|
||||
//! reference: Slint's create modal closes on the success path only too, but its
|
||||
//! `close()` is not refused mid-flight, so the scrim can dismiss a panel whose
|
||||
//! write is still running. Here `close()` is a no-op while busy, and a failure
|
||||
//! keeps what the user typed on screen and toasts.
|
||||
//!
|
||||
//! # Threading
|
||||
//!
|
||||
//! Invokables run on the Qt thread: mutate the `Mutex`, publish, `crate::spawn`
|
||||
//! the rest. Every `local_playlist_qt` call is `spawn_blocking`-only.
|
||||
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct CreateState {
|
||||
open: bool,
|
||||
/// True while the app is offline: creation can only produce a local
|
||||
/// playlist, so the toggle shows ON and LOCKED with a hint
|
||||
/// (CreatePlaylistModal.slint:184-216).
|
||||
offline_locked: bool,
|
||||
/// Folders offered by the dropdown, `(id, name)`. Index 0 is always
|
||||
/// ("", "No folder").
|
||||
folders: Vec<(String, String)>,
|
||||
busy: bool,
|
||||
}
|
||||
|
||||
static CREATE: LazyLock<Mutex<CreateState>> = LazyLock::new(|| Mutex::new(CreateState::default()));
|
||||
|
||||
fn state() -> std::sync::MutexGuard<'static, CreateState> {
|
||||
CREATE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct FolderOpt {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CreateDoc {
|
||||
open: bool,
|
||||
offline_locked: bool,
|
||||
busy: bool,
|
||||
folders: Vec<FolderOpt>,
|
||||
}
|
||||
|
||||
/// Serialize a snapshot. Split from [`create_doc`] so the shape is testable
|
||||
/// without touching the process-global `CREATE`.
|
||||
fn create_doc_of(st: CreateState) -> String {
|
||||
serde_json::to_string(&CreateDoc {
|
||||
open: st.open,
|
||||
offline_locked: st.offline_locked,
|
||||
busy: st.busy,
|
||||
folders: st
|
||||
.folders
|
||||
.into_iter()
|
||||
.map(|(id, name)| FolderOpt { id, name })
|
||||
.collect(),
|
||||
})
|
||||
.unwrap_or_else(|_| "{\"open\":false}".to_string())
|
||||
}
|
||||
|
||||
fn create_doc() -> String {
|
||||
create_doc_of(state().clone())
|
||||
}
|
||||
|
||||
/// Publish the document. Also called from `boot()` so the closed FULL shape is
|
||||
/// on the QML side before the first open.
|
||||
pub(crate) fn publish() {
|
||||
let json = create_doc();
|
||||
crate::playlist_edit_bridge::ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_create_json(cxx_qt_lib::QString::from(json.as_str()));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Open / close
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Sidebar "+" — seed and show the modal.
|
||||
///
|
||||
/// The folder list is built HERE, from the same source the sidebar tree reads,
|
||||
/// and HIDDEN FOLDERS ARE DROPPED. The reference builds it from
|
||||
/// `SidebarState.folders`, which is already the visible set, so reading the raw
|
||||
/// table would offer a folder the tree refuses to show — a playlist filed into
|
||||
/// nowhere the user can see. Same rule `SidebarRowMenu.qml`'s `visibleFolders`
|
||||
/// spells out for the move-to-folder list.
|
||||
pub(crate) fn open() {
|
||||
let offline = crate::offline_fwd::engine().status().is_offline();
|
||||
let mut folders: Vec<(String, String)> = vec![(String::new(), qbz_i18n::t("No folder"))];
|
||||
folders.extend(
|
||||
crate::folders_qt::load_folders_full()
|
||||
.into_iter()
|
||||
.filter(|f| !f.is_hidden)
|
||||
.map(|f| (f.id, f.name)),
|
||||
);
|
||||
{
|
||||
let mut st = state();
|
||||
st.open = true;
|
||||
st.busy = false;
|
||||
st.offline_locked = offline;
|
||||
st.folders = folders;
|
||||
}
|
||||
publish();
|
||||
}
|
||||
|
||||
/// Dismiss. Refused while a create is in flight (D22).
|
||||
pub(crate) fn close() {
|
||||
{
|
||||
let mut st = state();
|
||||
if st.busy {
|
||||
return;
|
||||
}
|
||||
st.open = false;
|
||||
}
|
||||
publish();
|
||||
}
|
||||
|
||||
fn settle_failure(msgid: &'static str) {
|
||||
{
|
||||
let mut st = state();
|
||||
st.busy = false;
|
||||
}
|
||||
publish();
|
||||
crate::toast_qt::error(qbz_i18n::t(msgid));
|
||||
}
|
||||
|
||||
fn settle_success(new_id: String, local: bool) {
|
||||
{
|
||||
let mut st = state();
|
||||
st.busy = false;
|
||||
st.open = false;
|
||||
}
|
||||
publish();
|
||||
// The offline-safe verb: `reload_sidebar` early-returns while offline, and
|
||||
// a local playlist created there would never appear in the tree.
|
||||
if local {
|
||||
crate::reload_sidebar_including_local();
|
||||
} else {
|
||||
crate::reload_sidebar();
|
||||
}
|
||||
// Land on it, exactly as the reference does after either arm
|
||||
// (`main.rs:21554` / `:21598`). `open_playlist` routes a `local:` id to the
|
||||
// local loader and does not offline-gate it.
|
||||
crate::open_playlist(new_id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create. Every field is a QML-LOCAL draft and arrives as an argument
|
||||
/// (`MyQbzModals.qml`'s convention); nothing about the form lives in Rust
|
||||
/// except `busy` and the seeds.
|
||||
///
|
||||
/// `folder_id` is `""` for "No folder".
|
||||
pub(crate) fn submit(
|
||||
name: &str,
|
||||
description: &str,
|
||||
folder_id: &str,
|
||||
is_public: bool,
|
||||
offline_only: bool,
|
||||
) {
|
||||
let name = name.trim().to_string();
|
||||
let description = description.trim().to_string();
|
||||
let folder_id = folder_id.to_string();
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut st = state();
|
||||
if st.busy || !st.open {
|
||||
return;
|
||||
}
|
||||
st.busy = true;
|
||||
}
|
||||
publish();
|
||||
|
||||
let offline_now = crate::offline_fwd::engine().status().is_offline();
|
||||
if offline_only || offline_now {
|
||||
// LOCAL arm. A pure DB write — no session, no network, and the one
|
||||
// path an account-less user has. `offline_only` is stored as TRUE even
|
||||
// when it was forced by being offline (reference note at
|
||||
// `main.rs:21528-21530`): the flag is what "Upload to Qobuz" reads, and
|
||||
// Edit can unmark it later.
|
||||
crate::spawn(async move {
|
||||
let created = tokio::task::spawn_blocking(move || {
|
||||
let desc = if description.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(description.as_str())
|
||||
};
|
||||
crate::local_playlist_qt::create_blocking(&name, desc, true)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
match created {
|
||||
Some(new_id) => {
|
||||
log::info!("[qbz-qt] local playlist created: {new_id}");
|
||||
settle_success(new_id, true);
|
||||
}
|
||||
None => {
|
||||
log::error!("[qbz-qt] create local playlist failed");
|
||||
settle_failure("Couldn't create the playlist");
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let runtime = crate::app();
|
||||
crate::spawn(async move {
|
||||
let desc = if description.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(description.as_str())
|
||||
};
|
||||
match runtime.core().create_playlist(&name, desc, is_public).await {
|
||||
Ok(playlist) => {
|
||||
let new_id = playlist.id.to_string();
|
||||
// Folder assignment is a LOCAL db write keyed on the new Qobuz
|
||||
// id, and it runs BEFORE the sidebar reload so the tree draws
|
||||
// the playlist already filed (reference ordering,
|
||||
// `main.rs:21575-21584`).
|
||||
if !folder_id.is_empty() {
|
||||
let pid = playlist.id;
|
||||
let fid = folder_id.clone();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
crate::folders_qt::move_playlist(pid, Some(fid.as_str()));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
log::info!("[qbz-qt] playlist created: {} ({})", playlist.name, playlist.id);
|
||||
settle_success(new_id, false);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[qbz-qt] create playlist failed: {e}");
|
||||
settle_failure("Couldn't create the playlist");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The closed document is FULL-SHAPE and parseable: the modal's bindings
|
||||
/// read `doc.open`, `doc.busy`, `doc.offlineLocked` and `doc.folders` on
|
||||
/// the pre-publish frame, and an absent key there is a QML warning per
|
||||
/// binding, per frame.
|
||||
#[test]
|
||||
fn closed_doc_is_full_shape() {
|
||||
let doc: serde_json::Value =
|
||||
serde_json::from_str(&create_doc_of(CreateState::default())).unwrap();
|
||||
assert_eq!(doc["open"], false);
|
||||
assert_eq!(doc["busy"], false);
|
||||
assert_eq!(doc["offlineLocked"], false);
|
||||
assert!(doc["folders"].as_array().is_some_and(|a| a.is_empty()));
|
||||
}
|
||||
|
||||
/// The dropdown is camelCase on the wire and index 0 is the "no folder"
|
||||
/// sentinel with an EMPTY id — `submit` reads that emptiness, not the
|
||||
/// index, so a reordered list cannot file a playlist into the wrong place.
|
||||
#[test]
|
||||
fn folder_options_carry_the_root_sentinel_first() {
|
||||
let st = CreateState {
|
||||
open: true,
|
||||
offline_locked: true,
|
||||
busy: false,
|
||||
folders: vec![
|
||||
(String::new(), "No folder".into()),
|
||||
("f1".into(), "Jazz".into()),
|
||||
],
|
||||
};
|
||||
let doc: serde_json::Value = serde_json::from_str(&create_doc_of(st)).unwrap();
|
||||
assert_eq!(doc["offlineLocked"], true);
|
||||
let folders = doc["folders"].as_array().unwrap();
|
||||
assert_eq!(folders.len(), 2);
|
||||
assert_eq!(folders[0]["id"], "");
|
||||
assert_eq!(folders[1]["id"], "f1");
|
||||
assert_eq!(folders[1]["name"], "Jazz");
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,19 @@ pub mod qbz_playlist_edit_bridge {
|
||||
// descLoaded / isLocal / offlineOnly / busy. Parseable default so a
|
||||
// binding reading `doc.open` on the pre-publish frame cannot throw.
|
||||
#[qproperty(QString, edit_json)]
|
||||
/// The "New playlist" document (`playlist_create_qt`): open / busy /
|
||||
/// offlineLocked / folders. Same parseable-default rule as `edit_json`.
|
||||
///
|
||||
/// CREATE RIDES THE EDITOR'S SINGLETON rather than getting one of its
|
||||
/// own, which is where the reference splits it (`CreatePlaylistState`
|
||||
/// vs `EditPlaylistState`). One domain — a playlist's own metadata:
|
||||
/// name, description, folder, the offline-only flag — reached from the
|
||||
/// same two surfaces (the sidebar and the manager), with two documents
|
||||
/// that never interact. A second QObject would buy the separation at
|
||||
/// the cost of a second boot() to forget, and this port has already
|
||||
/// paid that bill once: the bridge header's own note about a missing
|
||||
/// `boot()` being "silently and forever" dropped is not hypothetical.
|
||||
#[qproperty(QString, create_json)]
|
||||
|
||||
type QbzPlaylistEdit = super::QbzPlaylistEditRust;
|
||||
|
||||
@@ -119,6 +132,34 @@ pub mod qbz_playlist_edit_bridge {
|
||||
/// Dismiss. Refused while a save or delete is in flight (D22).
|
||||
#[qinvokable]
|
||||
fn close(self: Pin<&mut QbzPlaylistEdit>);
|
||||
|
||||
// --- "New playlist" (playlist_create_qt) -------------------------
|
||||
|
||||
/// Sidebar "+" — seed and open the create modal. Resolves the offline
|
||||
/// lock and the folder dropdown BEFORE it opens, so the panel never
|
||||
/// flashes an empty picker.
|
||||
#[qinvokable]
|
||||
fn open_create(self: Pin<&mut QbzPlaylistEdit>);
|
||||
|
||||
/// Create. Every field is a QML-local draft and arrives here as an
|
||||
/// argument; `folder_id` is `""` for "No folder". `offline_only` (or
|
||||
/// being offline) selects the LOCAL arm — a `local_playlists` row that
|
||||
/// never reaches Qobuz. That toggle is the only reason this modal had
|
||||
/// to come back: the POC shortcut derived it from connectivity, so an
|
||||
/// online user could not create a local playlist at all.
|
||||
#[qinvokable]
|
||||
fn create_submit(
|
||||
self: Pin<&mut QbzPlaylistEdit>,
|
||||
name: QString,
|
||||
description: QString,
|
||||
folder_id: QString,
|
||||
is_public: bool,
|
||||
offline_only: bool,
|
||||
);
|
||||
|
||||
/// Dismiss the create modal. Refused while the write is in flight.
|
||||
#[qinvokable]
|
||||
fn close_create(self: Pin<&mut QbzPlaylistEdit>);
|
||||
}
|
||||
|
||||
impl cxx_qt::Threading for QbzPlaylistEdit {}
|
||||
@@ -129,6 +170,7 @@ use qbz_playlist_edit_bridge::QbzPlaylistEdit;
|
||||
/// Rust side of the playlist-editor bridge (plain storage, phase-1 pattern).
|
||||
pub struct QbzPlaylistEditRust {
|
||||
edit_json: QString,
|
||||
create_json: QString,
|
||||
}
|
||||
|
||||
impl Default for QbzPlaylistEditRust {
|
||||
@@ -137,6 +179,7 @@ impl Default for QbzPlaylistEditRust {
|
||||
// Closed and parseable: `doc.open === true` on frame 1 is false,
|
||||
// and no binding in the modal throws before the first publish.
|
||||
edit_json: QString::from("{\"open\":false}"),
|
||||
create_json: QString::from("{\"open\":false}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,9 +203,10 @@ impl qbz_playlist_edit_bridge::QbzPlaylistEdit {
|
||||
if QT_THREAD.set(self.qt_thread()).is_err() {
|
||||
log::warn!("[qbz-qt] playlist edit Qt thread already registered");
|
||||
}
|
||||
// Publish the closed document once, so the modal's parse sees the
|
||||
// Publish the closed documents once, so each modal's parse sees the
|
||||
// full shape rather than the terser Default literal.
|
||||
crate::playlist_edit_qt::publish();
|
||||
crate::playlist_create_qt::publish();
|
||||
}
|
||||
|
||||
pub fn open(self: Pin<&mut Self>, id: QString) {
|
||||
@@ -189,4 +233,29 @@ impl qbz_playlist_edit_bridge::QbzPlaylistEdit {
|
||||
pub fn close(self: Pin<&mut Self>) {
|
||||
crate::playlist_edit_qt::close();
|
||||
}
|
||||
|
||||
pub fn open_create(self: Pin<&mut Self>) {
|
||||
crate::playlist_create_qt::open();
|
||||
}
|
||||
|
||||
pub fn create_submit(
|
||||
self: Pin<&mut Self>,
|
||||
name: QString,
|
||||
description: QString,
|
||||
folder_id: QString,
|
||||
is_public: bool,
|
||||
offline_only: bool,
|
||||
) {
|
||||
crate::playlist_create_qt::submit(
|
||||
&name.to_string(),
|
||||
&description.to_string(),
|
||||
&folder_id.to_string(),
|
||||
is_public,
|
||||
offline_only,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn close_create(self: Pin<&mut Self>) {
|
||||
crate::playlist_create_qt::close();
|
||||
}
|
||||
}
|
||||
@@ -663,10 +663,6 @@ pub mod qbz_shell {
|
||||
/// offline (contract §4.7 / block 6).
|
||||
#[qinvokable]
|
||||
fn sidebar_open_folder_popup(self: Pin<&mut QbzShell>, folder_id: QString);
|
||||
/// Sidebar "+" — create an empty playlist (single core call), then
|
||||
/// reload the tree.
|
||||
#[qinvokable]
|
||||
fn create_playlist(self: Pin<&mut QbzShell>);
|
||||
/// Sidebar cover dispatch: JSON array of cover URLS (the tree's
|
||||
/// collage is url-keyed, unlike the feed's artKey).
|
||||
#[qinvokable]
|
||||
@@ -1288,10 +1284,6 @@ impl qbz_shell::QbzShell {
|
||||
crate::sidebar_open_folder_popup(&folder_id.to_string());
|
||||
}
|
||||
|
||||
pub fn create_playlist(self: Pin<&mut Self>) {
|
||||
crate::create_playlist();
|
||||
}
|
||||
|
||||
pub fn sidebar_artwork_window(self: Pin<&mut Self>, urls_json: QString) {
|
||||
crate::sidebar_artwork_window(urls_json.to_string());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user