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:
vicrodh committed 2026-08-19 23:55:25 -06:00
1 parent e1876c15f0
commit c11bdf0654
8 files changed
+883 -85

No files matched your search

+1
View File
@@ -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()
}
}
}
}
}
}
}
+10
View File
@@ -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
+15 -10
View File
@@ -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.
+1 -66
View File
@@ -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();
+343
View File
@@ -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");
}
}
+70 -1
View File
@@ -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();
}
}
-8
View File
@@ -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());
}