diff --git a/crates/qbz-app/src/offline_mode/mod.rs b/crates/qbz-app/src/offline_mode/mod.rs index d046f5fce..a89d8dcbc 100644 --- a/crates/qbz-app/src/offline_mode/mod.rs +++ b/crates/qbz-app/src/offline_mode/mod.rs @@ -160,22 +160,6 @@ impl OfflineModeEngine { store.get_settings() } - /// Persist the network-folders-in-manual-offline policy flag. - /// - /// NOTE (2026-06-10): no UI calls this anymore — the Slint "Show Network - /// Folder Content" toggle was removed when library visibility stopped - /// depending on offline mode (owner verdict; see qbz-slint's - /// NETWORK-FOLDER VISIBILITY note). Kept (pub, no dead-code warning in a - /// lib crate) because the store column must stay Tauri-DB-compatible. - pub fn set_show_network_folders(&self, enabled: bool) -> Result<(), String> { - let guard = self - .store - .lock() - .map_err(|e| format!("offline store lock poisoned: {}", e))?; - let store = guard.as_ref().ok_or("No active session")?; - store.set_show_network_folders_in_manual_offline(enabled) - } - /// Restore the Tauri manual-offline policy: submit scrobbles through the /// network immediately even while the Qobuz offline gate is induced. /// Enabling it atomically disables accumulated mode in the shared store. diff --git a/crates/qbz-app/src/settings/album_play_history.rs b/crates/qbz-app/src/settings/album_play_history.rs index 486114676..191a691c2 100644 --- a/crates/qbz-app/src/settings/album_play_history.rs +++ b/crates/qbz-app/src/settings/album_play_history.rs @@ -55,7 +55,7 @@ fn init_schema(conn: &Connection) -> rusqlite::Result<()> { )?; // PLAYBACK CONTEXT of the event — "album" | "playlist" | "" (unknown). // - // Additive, and it has to be: this .db is SHARED with the Slint build, + // Additive, and it has to be: this .db is shared with existing installs, // whose tree is frozen and whose INSERT names its columns explicitly // (`INSERT INTO album_play_events (album_id, occurred_at)`), so an extra // column with a DEFAULT is invisible to it and it simply writes `''`. @@ -75,23 +75,23 @@ fn open_db() -> Option { let path = db_path()?; if let Some(parent) = path.parent() { if let Err(e) = std::fs::create_dir_all(parent) { - log::warn!("[qbz-slint] album_play_history dir create failed: {e}"); + log::warn!("[qbz-app] album_play_history dir create failed: {e}"); return None; } } let conn = match Connection::open(&path) { Ok(c) => c, Err(e) => { - log::warn!("[qbz-slint] album_play_history open failed: {e}"); + log::warn!("[qbz-app] album_play_history open failed: {e}"); return None; } }; // ADR-002: WAL for any SQLite store touched off the UI thread. if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;") { - log::warn!("[qbz-slint] album_play_history pragma failed: {e}"); + log::warn!("[qbz-app] album_play_history pragma failed: {e}"); } if let Err(e) = init_schema(&conn) { - log::warn!("[qbz-slint] album_play_history schema failed: {e}"); + log::warn!("[qbz-app] album_play_history schema failed: {e}"); return None; } Some(conn) @@ -149,7 +149,7 @@ fn record_on(conn: &Connection, m: &AlbumPlayMeta, now: i64) { VALUES (?, ?, ?)", params![m.album_id, now, m.context_kind], ) { - log::warn!("[qbz-slint] album_play_history insert event failed: {e}"); + log::warn!("[qbz-app] album_play_history insert event failed: {e}"); } if let Err(e) = conn.execute( r#" @@ -181,7 +181,7 @@ fn record_on(conn: &Connection, m: &AlbumPlayMeta, now: i64) { now ], ) { - log::warn!("[qbz-slint] album_play_history upsert meta failed: {e}"); + log::warn!("[qbz-app] album_play_history upsert meta failed: {e}"); } } @@ -236,7 +236,6 @@ fn query_on(conn: &Connection, limit: Option) -> Vec { /// Record a play. Called from `playback::record_recent` when a track starts /// audible playback. No-op when the album id is empty (some local/Plex /// sources carry none — same guard as the recently-played rail). -#[allow(dead_code)] // wired by playback::record_recent pub fn record_album_play(m: AlbumPlayMeta) { if m.album_id.is_empty() { return; @@ -252,13 +251,11 @@ pub fn record_album_play(m: AlbumPlayMeta) { } /// The top `limit` most-played albums (the carousel). -#[allow(dead_code)] // wired by home/foryou pub fn top_albums(limit: u32) -> Vec { with_db(|conn| Some(query_on(conn, Some(limit)))).unwrap_or_default() } /// Every played album, ranked (the "View all" page). -#[allow(dead_code)] // wired by the View-all loader pub fn all_albums() -> Vec { with_db(|conn| Some(query_on(conn, None))).unwrap_or_default() } diff --git a/crates/qbz-i18n/src/lib.rs b/crates/qbz-i18n/src/lib.rs index f02826354..fa472db25 100644 --- a/crates/qbz-i18n/src/lib.rs +++ b/crates/qbz-i18n/src/lib.rs @@ -1,8 +1,8 @@ //! qbz-i18n — frontend-agnostic gettext-style translation catalog. //! -//! Reads the same gettext `.po` files Slint bundles, keyed by -//! `msgid = English source string` (no `msgctxt`). Reusable by any frontend -//! (Slint / TUI / headless) — no slint or tauri dependencies (ADR-006). +//! Reads QBZ's gettext `.po` files, keyed by `msgid = English source string` +//! (no `msgctxt`). Reusable by Qt, TUI, or headless consumers, without a +//! frontend dependency (ADR-006). pub mod plural; pub mod po; @@ -19,18 +19,10 @@ const LANGS: [&str; 8] = ["en", "es", "de", "fr", "pt", "ru", "ja", "nl"]; /// Embedded `.po` sources, OWNED BY THIS CRATE. Path is relative to this file /// (`crates/qbz-i18n/src/lib.rs`): `../` = `qbz-i18n/`. /// -/// They used to live in `crates/qbz-ui/translations/` and be reached with -/// `../../qbz-ui/...`, which made this crate's own doc comment ("frontend- -/// agnostic ... no slint or tauri dependencies, ADR-006") false in the one way -/// that mattered: no dependency in `Cargo.toml`, but eight `include_str!` -/// reaching into the Slint crate's directory. Deleting `qbz-ui` — which is -/// planned, before the Qt release — would have failed to compile `qbz-i18n` -/// and with it `qbz-qt`, i.e. the whole app, over a data directory nobody -/// thought of as a dependency. The extraction had taken the code and left the -/// data behind. -/// -/// `qbz-ui/build.rs` now borrows them back through `../qbz-i18n/translations` -/// for as long as the Slint tree exists. +/// These catalogs are intentionally owned here so deleting or replacing a +/// frontend cannot break the shared translation crate through hidden +/// `include_str!` paths. The historical `qbz-ui.po` filename is an on-disk +/// catalog name, not a dependency on the retired frontend. const PO_EN: &str = include_str!("../translations/en/LC_MESSAGES/qbz-ui.po"); const PO_ES: &str = include_str!("../translations/es/LC_MESSAGES/qbz-ui.po"); const PO_DE: &str = include_str!("../translations/de/LC_MESSAGES/qbz-ui.po"); diff --git a/crates/qbz-player/src/remote_stream.rs b/crates/qbz-player/src/remote_stream.rs index 40351c6ef..9d2e98ce9 100644 --- a/crates/qbz-player/src/remote_stream.rs +++ b/crates/qbz-player/src/remote_stream.rs @@ -9,11 +9,8 @@ //! //! ADR-006 (frontend-agnostic core) is the rule this restores. //! -//! DEBT: the Slint copy is still there and untouched, deliberately — moving it -//! would force a rebuild of the Slint binary, which pulls `qbz-ui` and its -//! ~30 GB compile that this box cannot verify. Collapse the two the next time -//! the Slint side is built: `crates/qbz/src/remote_stream.rs` becomes -//! `pub use qbz_player::remote_stream::*;`. +//! This shared extraction is now the sole implementation used by the +//! application frontends and renderer paths. //! Shared HTTP streaming feeder. //! diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 488cda643..c45477067 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -417,6 +417,7 @@ fn main() { "src/shader_scene_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", + "src/link_resolver_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", @@ -470,6 +471,7 @@ fn main() { "qml/controls/FolderEditPanel.qml", "qml/controls/FolderModals.qml", "qml/controls/GroupHeader.qml", + "qml/controls/LinkResolverModal.qml", "qml/controls/MyQbzModals.qml", "qml/controls/PlaylistCreateModal.qml", "qml/controls/PlaylistEditModal.qml", diff --git a/crates/qbz-qt/qml/LoginScreen.qml b/crates/qbz-qt/qml/LoginScreen.qml index 3e20cc5ec..42a9fc4aa 100644 --- a/crates/qbz-qt/qml/LoginScreen.qml +++ b/crates/qbz-qt/qml/LoginScreen.qml @@ -13,6 +13,7 @@ import QtQuick import com.blitzfc.qbz +import "controls" import "theme" Rectangle { @@ -154,36 +155,13 @@ Rectangle { // --- Sign in --------------------------------------------- // Opens the user's default web browser (no embedded webview). - Rectangle { + QbzPrimaryButton { id: signInButton width: parent.width - height: 48 - radius: theme.radiusSm property bool canSignIn: root.tosAccepted && QbzSession.loginPhase === 0 - opacity: canSignIn ? 1.0 : 0.5 - color: signInArea.containsMouse && canSignIn ? theme.accentHover : theme.accent - Behavior on color { ColorAnimation { duration: 150 } } - - Text { - anchors.centerIn: parent - text: QbzSession.tr("Sign in with your browser", QbzSession.trRev) - // On the accent fill (accentHover under the cursor). NOT - // a departure from primitives/QbzPrimaryButton.slint:36 - // (`Theme.accent-text`), which is what the twin returns on - // 34 of the 35 palettes — a floor for rose-pine-dawn only - // (accent-text #575279 on accent #d7827e is 2.56:1). - color: theme.accentGlyphColor - font.pixelSize: theme.fontButton - font.weight: theme.weightSemibold - } - MouseArea { - id: signInArea - anchors.fill: parent - enabled: signInButton.canSignIn - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: QbzSession.signInViaBrowser() - } + label: QbzSession.tr("Sign in with your browser", QbzSession.trRev) + btnEnabled: signInButton.canSignIn + onClicked: QbzSession.signInViaBrowser() } Item { width: 1; height: theme.spacingMd } @@ -398,26 +376,43 @@ Rectangle { // opens the GUEST profile (user 0) — Local Library only, // adopted by the account on the first real login. Item { + id: offlineButton anchors.horizontalCenter: parent.horizontalCenter width: offlineText.implicitWidth height: offlineText.implicitHeight + 3 + activeFocusOnTab: true + Accessible.role: Accessible.Button + Accessible.name: offlineText.text + Accessible.onPressAction: QbzSession.startOffline() + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Return + || event.key === Qt.Key_Enter + || event.key === Qt.Key_Space)) { + QbzSession.startOffline() + event.accepted = true + } + } Text { id: offlineText text: QbzSession.tr("Start offline (no access to Qobuz™ services)", QbzSession.trRev) - color: offlineArea.containsMouse ? theme.accent : theme.textMuted + color: offlineArea.containsMouse || offlineButton.activeFocus + ? theme.accent : theme.textMuted font.pixelSize: theme.fontLink } Rectangle { y: offlineText.implicitHeight + 1 width: offlineText.implicitWidth height: 1 - color: offlineArea.containsMouse ? theme.accent : theme.textMuted + color: offlineArea.containsMouse || offlineButton.activeFocus + ? theme.accent : theme.textMuted } MouseArea { id: offlineArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor + onPressed: offlineButton.forceActiveFocus() onClicked: QbzSession.startOffline() } } diff --git a/crates/qbz-qt/qml/Main.qml b/crates/qbz-qt/qml/Main.qml index e7fd91460..3650b731b 100644 --- a/crates/qbz-qt/qml/Main.qml +++ b/crates/qbz-qt/qml/Main.qml @@ -262,6 +262,7 @@ ApplicationWindow { // after QbzSession like every domain singleton. The B2 dispatcher // (AppShell-root Keys.onPressed) is the only other QML side. QbzHotkeys.boot() + QbzLink.boot() // Search (2026-08-03 cortinilla-parity contract, commit C0): the // domain extracted from QbzBridge. Its position among the other // domain singletons is not load-bearing — every boot() here runs @@ -740,9 +741,11 @@ ApplicationWindow { // Both reference close arms flush the session BEFORE hiding, "even when // only hiding — the process may be killed from the tray / shell without // a real quit afterwards" (crates/qbz/src/main.rs:23255-23256). The Qt - // port has no session_persist module, so the geometry flush is the - // whole of what is owed. On a miniplayer enter it is harmless: it - // writes the size the exit is about to restore. + // session persistence itself is flushed by the Rust exit path; this + // early geometry flush is still owed before a hide because the + // process may later be killed without a QML close event. On a + // miniplayer enter it is harmless: it writes the size the exit is + // about to restore. window.persistWindowGeometryOnExit() window.hide() QbzTray.setWindowShown(false) diff --git a/crates/qbz-qt/qml/controls/LinkResolverModal.qml b/crates/qbz-qt/qml/controls/LinkResolverModal.qml new file mode 100644 index 000000000..899f3bb65 --- /dev/null +++ b/crates/qbz-qt/qml/controls/LinkResolverModal.qml @@ -0,0 +1,205 @@ +// App-wide music-link resolver. QbzLink owns state and async resolution; +// this file is only the modal surface and survives whichever menu/hotkey +// opened it. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + visible: QbzLink.modalOpen + + QbzTheme { id: theme } + + Keys.onEscapePressed: function(event) { + QbzLink.close() + event.accepted = true + } + + onVisibleChanged: { + if (visible) + urlInput.focusField() + } + + Rectangle { + anchors.fill: parent + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzLink.close() + } + } + + Rectangle { + id: card + anchors.centerIn: parent + width: Math.min(parent.width - 80, 520) + height: panel.implicitHeight + 48 + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + + MouseArea { anchors.fill: parent } + + Column { + id: panel + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 24 + spacing: 16 + + Item { + width: parent.width + height: 28 + Text { + anchors.left: parent.left + anchors.right: closeButton.left + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Open Qobuz Link", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Item { + id: closeButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 17 + height: 17 + tintName: closeArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzLink.close() + } + } + } + + Row { + width: parent.width + height: 40 + spacing: 8 + + Item { + width: 36 + height: 40 + + Image { + anchors.centerIn: parent + width: 24 + height: 24 + fillMode: Image.PreserveAspectFit + visible: QbzLink.platform === "qobuz" + || QbzLink.platform === "spotify" + || QbzLink.platform === "apple" + || QbzLink.platform === "deezer" + source: QbzLink.platform === "qobuz" + ? "../assets/brand/qobuz-logo-filled.svg" + : QbzLink.platform === "spotify" + ? "../assets/brand/spotify-logo.svg" + : QbzLink.platform === "apple" + ? "../assets/brand/apple-music-logo.svg" + : "../assets/brand/deezer-logo.svg" + } + QbzIcon { + anchors.centerIn: parent + width: 24 + height: 24 + visible: QbzLink.platform === "tidal" + name: "tidal-tidal" + tintName: "accent" + } + QbzIcon { + anchors.centerIn: parent + width: 22 + height: 22 + visible: QbzLink.platform !== "qobuz" + && QbzLink.platform !== "spotify" + && QbzLink.platform !== "apple" + && QbzLink.platform !== "deezer" + && QbzLink.platform !== "tidal" + name: "link" + tintName: "muted" + } + } + + QbzLineEdit { + id: urlInput + width: parent.width - 36 - goButton.width - 2 * parent.spacing + height: 40 + text: QbzLink.url + placeholder: QbzSession.tr("Paste a Qobuz, Spotify, Apple Music, Tidal, Deezer or song.link URL", QbzSession.trRev) + enabled: !QbzLink.resolving + opacity: enabled ? 1.0 : 0.6 + onEdited: function(value) { QbzLink.urlEdited(value) } + onAccepted: function(value) { QbzLink.submit(value) } + } + + QbzPrimaryButton { + id: goButton + anchors.verticalCenter: parent.verticalCenter + label: QbzLink.resolving + ? QbzSession.tr("Resolving…", QbzSession.trRev) + : QbzSession.tr("Go", QbzSession.trRev) + btnHeight: 40 + labelSize: theme.fontBody + btnEnabled: QbzLink.url.trim() !== "" && !QbzLink.resolving + onClicked: QbzLink.submit(QbzLink.url) + } + } + + Text { + width: parent.width + visible: QbzLink.error !== "" + text: QbzLink.error + color: theme.danger + font.pixelSize: theme.fontLegal + wrapMode: Text.WordWrap + } + + Rectangle { + width: parent.width + height: playlistColumn.implicitHeight + 24 + visible: QbzLink.playlistDetected + radius: theme.radiusSm + color: theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + + Column { + id: playlistColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + spacing: 10 + Text { + width: parent.width + text: QbzSession.tr("This looks like a playlist.", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontLegal + wrapMode: Text.WordWrap + } + QbzPrimaryButton { + label: QbzSession.tr("Open Playlist Importer", QbzSession.trRev) + btnHeight: 34 + labelSize: theme.fontLegal + onClicked: QbzLink.openImporter() + } + } + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/QbzPrimaryButton.qml b/crates/qbz-qt/qml/controls/QbzPrimaryButton.qml index cd537d505..20b53ec69 100644 --- a/crates/qbz-qt/qml/controls/QbzPrimaryButton.qml +++ b/crates/qbz-qt/qml/controls/QbzPrimaryButton.qml @@ -25,11 +25,9 @@ // arm is the fixed danger red and therefore uses the confirm-standard's fixed // white label; reusing an accent-derived dark glyph there loses contrast. // -// NOT ported (deliberate, both are port-wide gaps rather than MyQBZ ones): -// the .slint's FocusScope Enter/Space activation and its keyboard focus ring -// (:62-86). This port has no keyboard/shortcut layer at all — the whole tree -// carries two key handlers (QbzLineEdit.qml:157, HeaderBar.qml:551) — so -// wiring one here would be the only focusable button in the app. +// Keyboard/accessibility contract: Tab focus, Enter/Space activation, a +// visible focus ring and the Qt accessibility press action all converge on +// the same `clicked` signal as the MouseArea. import QtQuick import com.blitzfc.qbz @@ -59,6 +57,26 @@ Rectangle { implicitWidth: lbl.implicitWidth + 40 radius: theme.radiusSm opacity: root.btnEnabled ? 1.0 : 0.5 + activeFocusOnTab: root.btnEnabled + Accessible.role: Accessible.Button + Accessible.name: root.label + Accessible.onPressAction: { + if (root.btnEnabled) + root.clicked() + } + border.width: root.activeFocus ? 2 : 0 + border.color: root.destructive ? "#ffffff" : theme.accentGlyphColor + + Keys.onPressed: function (event) { + if (root.btnEnabled + && !event.isAutoRepeat + && (event.key === Qt.Key_Return + || event.key === Qt.Key_Enter + || event.key === Qt.Key_Space)) { + root.clicked() + event.accepted = true + } + } color: root.destructive ? ((btnArea.containsMouse && root.btnEnabled) @@ -86,6 +104,7 @@ Rectangle { enabled: root.btnEnabled hoverEnabled: root.btnEnabled cursorShape: root.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onPressed: root.forceActiveFocus() onClicked: root.clicked() } } diff --git a/crates/qbz-qt/qml/controls/QbzToast.qml b/crates/qbz-qt/qml/controls/QbzToast.qml index 272807a3d..1249992b3 100644 --- a/crates/qbz-qt/qml/controls/QbzToast.qml +++ b/crates/qbz-qt/qml/controls/QbzToast.qml @@ -1,19 +1,13 @@ -// QbzToast — the app's in-app toast host (primitives/Toast.slint), mounted -// ONCE in AppShell.qml as a sibling of the content frame. Until this file the -// port had NO toast seam at all: TrackRow.qml:597-600, LabelView.qml:25, -// LyricsPanel.qml:16, PlayerBar.qml:515-517, NowPlayingBarSmall.qml:461-463 -// and local_bulk.rs:190-193 each document the same gap and fall back to -// silence. Silence is not 1:1 — "Already in {name}" is the ONLY signal that a -// duplicate add did nothing, and MyQBZ alone routes 26 distinct messages -// through crate::toast. +// QbzToast — the app's in-app toast host, mounted once in AppShell.qml as a +// sibling of the content frame. Rust owns publication and the settings gate; +// this component owns presentation and lifetime. // // CONTRACT. Rust publishes `QbzShell.toastJson` -// (`{ seq, kind, message, persistent }`, ToastDoc): ONE toast at a time, a new +// (`{ seq, kind, message }`, ToastDoc): ONE toast at a time, a new // one replaces the current, and Rust has ALREADY applied the `in_app_toasts` // settings gate (errors always pass) — this file must not re-read the setting. // The auto-hide timer lives HERE, keyed off `seq`, so Rust never owns a timer -// nor needs a hide round-trip: success/info 3000ms, warning 4000, error 5000, -// buffering persistent (qbz-slint-common/src/toast.rs:31-40). +// nor needs a hide round-trip: success/info 3000ms, warning 4000, error 5000. // // The component is ALWAYS in the tree (never `if`-gated) so the entry // animation runs; the close button's MouseArea is live only while a toast is @@ -32,12 +26,6 @@ // NavFlyout.qml:30-33 and every modal drop it). The 44px elevated fill on a // dark scene carries the separation. // -// ASSET + TINT PREREQUISITE (wiring items W21/W22): `circle-check-big`, -// `circle-alert` and `triangle-alert` are NOT in qml/assets/icons/* yet, and -// icon_tint_qt.rs's TOKENS has no `success` / `danger` tint. QbzIcon takes a -// tint NAME, never a colour, so both halves must land or the success/error -// glyphs render NOTHING with nothing logged. - import QtQuick import com.blitzfc.qbz import "../theme" @@ -60,8 +48,6 @@ Item { readonly property int seq: root.doc.seq || 0 readonly property string kind: root.doc.kind || "info" readonly property string message: root.doc.message || "" - readonly property bool persistent: root.doc.persistent === true - property bool showing: false readonly property int autoHideMs: root.kind === "error" ? 5000 @@ -70,7 +56,6 @@ Item { readonly property string iconName: root.kind === "success" ? "circle-check-big" : root.kind === "error" ? "circle-alert" : root.kind === "warning" ? "triangle-alert" - : root.kind === "buffering" ? "loader-circle" : "info" readonly property string iconTint: root.kind === "success" ? "success" : root.kind === "error" ? "danger" @@ -84,8 +69,7 @@ Item { return root.showing = true hideTimer.stop() - if (!root.persistent) - hideTimer.restart() + hideTimer.restart() } Timer { diff --git a/crates/qbz-qt/qml/controls/TagEditorWorkspace.qml b/crates/qbz-qt/qml/controls/TagEditorWorkspace.qml index c662ca465..989c2505d 100644 --- a/crates/qbz-qt/qml/controls/TagEditorWorkspace.qml +++ b/crates/qbz-qt/qml/controls/TagEditorWorkspace.qml @@ -911,7 +911,7 @@ Item { spacing: 7 SettingsButton { text: workspace.editor.tr("Apply to form") - iconName: "download" + iconName: "cloud-download" minWidth: 0 onClicked: workspace.editor.applyRemote() } diff --git a/crates/qbz-qt/qml/immersive/ImmersiveView.qml b/crates/qbz-qt/qml/immersive/ImmersiveView.qml index 7c9a46788..318931916 100644 --- a/crates/qbz-qt/qml/immersive/ImmersiveView.qml +++ b/crates/qbz-qt/qml/immersive/ImmersiveView.qml @@ -71,9 +71,8 @@ Item { // only if the window was NOT fullscreen before (port of // main.rs:8660-8716, as QML state — no 150ms timer, D3). property bool wasFullscreen: false - // Kiosk seam: Qt has no kiosk screen yet, so this is always false and - // the kiosk shell-restore arm is a documented no-op (D4) — the kiosk - // Loader contract is 2026-08-02-kiosk-port §4.6 (Claude). + // The kiosk host sets this true so closing Immersive never changes an + // appliance window's fullscreen state. Desktop leaves the default false. property bool preKiosk: false Connections { diff --git a/crates/qbz-qt/qml/rows/TrackRow.qml b/crates/qbz-qt/qml/rows/TrackRow.qml index 60021b2b5..fd7e854cd 100644 --- a/crates/qbz-qt/qml/rows/TrackRow.qml +++ b/crates/qbz-qt/qml/rows/TrackRow.qml @@ -1339,32 +1339,11 @@ Rectangle { else if (a === "cache") QbzPlayer.cacheTrack(root.item.id) else if (a === "uncache") QbzPlayer.uncacheTrack(root.item.id) else if (a === "recache") QbzPlayer.recacheTrack(root.item.id) - else if (a === "share-qobuz") root.copyToClipboard( - "https://open.qobuz.com/track/" + (root.item.id || "")) + else if (a === "share-qobuz") QbzAlbum.shareTrackQobuz(root.item.id || "") + else if (a === "share-songlink") QbzAlbum.shareTrackLink(root.item.id || "") else if (a === "track-info") root.openTrackInfo() } - // --- Share (share.rs::qobuz_track_url + copy_to_clipboard) ----------- - // The .slint arm copies the link and raises a toast; the Qt port has no - // toast seam yet (GLUE NEEDED), so the copy is silent — but it IS a real - // clipboard write. QtQuick exposes no Clipboard type; TextEdit.copy() is - // the supported route, kept in an INACTIVE Loader so a 16K-row list does - // not carry one TextEdit per row. - Loader { - id: clipLoader - active: false - sourceComponent: TextEdit { visible: false } - } - function copyToClipboard(text) { - if (!text || text === "") - return - clipLoader.active = true - clipLoader.item.text = text - clipLoader.item.selectAll() - clipLoader.item.copy() - clipLoader.active = false - } - // --- Track info (QbzAlbum.openTrackInfo + shell/TrackInfoModal) ------ // Same lazy-Loader reason: the modal is a full Popup tree and a list row // must not instantiate one per delegate. Activated on demand, torn down diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index 5ae62aa0e..9acd31288 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -715,6 +715,11 @@ Rectangle { PlaylistImportModal { anchors.fill: parent } + // Open Music Link — app-wide because both the header row and Ctrl+L open + // it, and a playlist result hands off to the importer beside it. + LinkResolverModal { + anchors.fill: parent + } // TrackReplacementModal — "Find available version" for a track Qobuz // pulled from the catalogue (2026-08-17 unavailable-tracks contract §6), // opened from the playlist row's context menu. diff --git a/crates/qbz-qt/qml/shell/HeaderBar.qml b/crates/qbz-qt/qml/shell/HeaderBar.qml index fa09d1889..3f74ad153 100644 --- a/crates/qbz-qt/qml/shell/HeaderBar.qml +++ b/crates/qbz-qt/qml/shell/HeaderBar.qml @@ -24,9 +24,7 @@ // Right: the tri-state offline status badge with its flyout (recovery // "Sign in" wired to QbzSession.recoveryLogin) and the app menu (user block + // Settings + Keyboard Shortcuts + Documentation + What's New + About QBZ + -// Report an Issue + Log Out + Close). ONE row is still missing against the -// Slint menu: Open Music Link, which needs the LinkResolver this port does not -// have yet. +// Report an Issue + Log Out + Close). // // The custom window chrome IS built (the note claiming it was skipped was a // fossil): the header itself is the drag surface (`startSystemMove()` below), @@ -1060,9 +1058,30 @@ Rectangle { } } } - // POC-NOTE: the manual-offline quick toggle (state 2) - // needs OfflineModeActions.set-offline — not wired; the - // status text above still renders. + // Manual/induced state: the same persisted action as the + // Settings toggle, exposed here as the quick path back + // online. + Row { + visible: root.badgeState === 2 + width: parent.width + height: visible ? 22 : 0 + spacing: 8 + Text { + width: parent.width - 48 + height: parent.height + text: QbzSession.tr("Offline mode", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: 12 + verticalAlignment: Text.AlignVCenter + } + QbzToggle { + checked: true + onToggled: function(value) { + QbzBridge.settingsBool("offline-mode-enabled", value) + badgeFlyout.close() + } + } + } } } } @@ -1174,10 +1193,6 @@ Rectangle { // --- App menu (user block + Settings + Keyboard Shortcuts + // Documentation + What's New + About QBZ + Log Out + Close) ------------ - // Still missing against the Slint menu, each needing a surface this port - // does not have yet: Open Music Link (the LinkResolver is not ported) and - // Report an Issue. (The older note here also listed Keyboard Shortcuts, - // Documentation, What's New and About QBZ, all of which have landed.) Popup { id: appMenu x: root.width - 234 - theme.spacingMd @@ -1288,8 +1303,15 @@ Rectangle { } } // Slint order (HeaderBar.slint:1175-1212): Settings, Open Music - // Link, Keyboard Shortcuts, Documentation… The Open Music Link - // row stays ABSENT (the LinkResolver is not ported, K3). + // Link, Keyboard Shortcuts, Documentation… + AppMenuItem { + name: "link" + label: QbzSession.tr("Open Qobuz Link", QbzSession.trRev) + onClicked: { + appMenu.close() + QbzLink.show() + } + } AppMenuItem { name: "keyboard" label: QbzSession.tr("Keyboard Shortcuts", QbzSession.trRev) diff --git a/crates/qbz-qt/qml/shell/KioskShell.qml b/crates/qbz-qt/qml/shell/KioskShell.qml index 16e82a557..cdbec8d65 100644 --- a/crates/qbz-qt/qml/shell/KioskShell.qml +++ b/crates/qbz-qt/qml/shell/KioskShell.qml @@ -23,6 +23,7 @@ import QtQuick import com.blitzfc.qbz import "../controls" +import "../immersive" import "../kiosk" import "../theme" @@ -497,18 +498,9 @@ Rectangle { // main.rs:1682); the extra history entry is the accepted // divergence rather than a second Rust entry point. onPlaying: QbzShell.navigateTo("nowplaying") - // R2 (contract §0): the tile's only action in the reference is - // opening the Immersive overlay (:652-654), and the kiosk's - // Immersive mount is the Immersive task's deliverable (the Loader - // at the bottom of this file). Until that task lands the ACTION is - // a sanctioned no-op — the tile's pixels and its focus ring are - // NOT deferred. Wiring QbzImmersive here instead would be worse - // than nothing: `open` would go true over an empty mount and the - // key guard below would then reject every key against a surface - // that renders nothing. - onVisualizer: { - // Intentionally empty — see the note above. - } + // The kiosk owns its Immersive mount below, so the visualizer tile + // follows the same open funnel as the desktop view-mode row. + onVisualizer: QbzImmersive.openFromMenu() onMenu: QbzShell.navigateTo("settings") } } @@ -627,14 +619,12 @@ Rectangle { // The desktop hangs its copy inside AppShell, so the kiosk shell must // mount its own; declared LAST = on top of everything. // - // SANCTIONED EMPTY MOUNT (contract §4.6 / R2): the kiosk Immersive - // surface is the Immersive task's deliverable and this Loader is the seam - // it lands in. The key-rejection arm above (QbzImmersive.open) ships NOW - // so the two compose the moment this gets a sourceComponent. - Loader { - id: immersiveMount + // The same surface as desktop, hosted locally because AppShell and + // KioskShell are sibling roots. `preKiosk` prevents an Immersive close + // from changing a fullscreen appliance window back to Windowed. + ImmersiveView { anchors.fill: root - active: QbzImmersive.open + preKiosk: true } // The two conditionally-mounted children, hoisted out of the column so diff --git a/crates/qbz-qt/qml/shell/LyricsLinesView.qml b/crates/qbz-qt/qml/shell/LyricsLinesView.qml index 41ac1760f..53ab61698 100644 --- a/crates/qbz-qt/qml/shell/LyricsLinesView.qml +++ b/crates/qbz-qt/qml/shell/LyricsLinesView.qml @@ -2,9 +2,8 @@ // crates/qbz-ui/ui/shell/LyricsLinesView.slint. // // Everything arrives through properties; nothing here references the sidebar -// or window geometry, so a future miniplayer / immersive host can mount it -// with its own padding, gap and type scale (neither host exists in the Qt -// port yet). +// or window geometry, so the miniplayer and Immersive hosts mount it with +// their own padding, gap and type scale. // // What it owns: // - the scrolling body: top/bottom spacers (so the first/last SYNCED line diff --git a/crates/qbz-qt/qml/shell/LyricsPanel.qml b/crates/qbz-qt/qml/shell/LyricsPanel.qml index 29b4c0864..e2acca0e6 100644 --- a/crates/qbz-qt/qml/shell/LyricsPanel.qml +++ b/crates/qbz-qt/qml/shell/LyricsPanel.qml @@ -13,12 +13,12 @@ // M 13/15 · L 16/19 · XL 20/24) and handed to the shared lines view together // with the persisted display prefs. // -// The Qt port has no toast host, so translation failures and the copy -// confirmation surface as an inline notice strip instead of vanishing. +// Translation failures and copy confirmation stay panel-local as an inline +// notice strip; the shared toast host is reserved for app-wide actions. // -// NOT hosted here (no such surface exists in the Qt port yet): the immersive -// lyrics-focus panel and the miniplayer lyrics surface. LyricsLinesView and -// LyricsSyncEngine are written host-agnostic so both can mount them later. +// LyricsLinesView and LyricsSyncEngine are host-agnostic: Immersive focus / +// split modes and the miniplayer mount the same document and sync math with +// their own layout knobs. import QtQuick import QtQuick.Controls @@ -306,7 +306,7 @@ Rectangle { } } - // --- Inline notice (this shell has no toast host) ----------------- + // --- Panel-local inline notice ------------------------------------ Rectangle { id: noticeStrip width: parent.width diff --git a/crates/qbz-qt/qml/shell/PlayerBar.qml b/crates/qbz-qt/qml/shell/PlayerBar.qml index ca38c17fe..7353a8dc3 100644 --- a/crates/qbz-qt/qml/shell/PlayerBar.qml +++ b/crates/qbz-qt/qml/shell/PlayerBar.qml @@ -53,8 +53,8 @@ // Connect is LIVE: the golden ConnectButton opens shell/QconnectFlyout.qml // (QbzQConnect — device list, set-active, connect/disconnect) and carries // the "Qobuz Connect: On/Off" hover bubble via the shell's tooltip overlay. -// Inert (TODO comments at the call sites): add-to-playlist. add-to-mixtape -// is LIVE since the MyQBZ domain landed (QbzMyQbzAdd.open). +// Add-to-playlist and add-to-mixtape are live through the two app-wide +// pickers (QbzPlaylistPicker / QbzMyQbzAdd). // // SIZE (project rule): the inline SongCard / TransportControls / FavToggle // components moved out to shell/SongCard.qml, shell/TransportControls.qml and diff --git a/crates/qbz-qt/qml/shell/TrackInfoModal.qml b/crates/qbz-qt/qml/shell/TrackInfoModal.qml index ba546ca41..c6c2d9446 100644 --- a/crates/qbz-qt/qml/shell/TrackInfoModal.qml +++ b/crates/qbz-qt/qml/shell/TrackInfoModal.qml @@ -24,8 +24,7 @@ // The shadow is still not added: it is a visual change owing its own parity // pass against Slint's blur, not a perf fix. // -// DATA — see the report's GLUE NEEDED; the Qt port has no track-info bridge -// yet. Contract: QbzAlbum.openTrackInfo(trackId) fetches and publishes +// DATA: QbzAlbum.openTrackInfo(trackId) fetches and publishes // QbzAlbum.trackInfoJson while QbzAlbum.trackInfoLoading is true. Document // shape (1:1 with crates/qbz/src/info_modals.rs `TrackInfoData`): // { "error": "", "title": "", "album": "", "artist": "", "artistId": "", @@ -36,9 +35,7 @@ // Every read is guarded, so while the glue is missing the modal degrades to // its (already 1:1) loading state instead of throwing. // -// Inert, matching the existing port precedent (cards/LabelCard.qml): the label -// and musician links render and hover exactly like the Slint but have no Qt -// invokable to call. The artist link IS live (QbzArtist.openArtist). +// Artist, label and musician links all route through their live Qt bridges. // // Split (project size rule): the scrolling content is TrackInfoBody.qml and // the two cell primitives are InfoMetaCell.qml / InfoCreditCell.qml — the @@ -96,13 +93,7 @@ Popup { function openFor(trackId) { if (!trackId || trackId === "") return - try { - QbzAlbum.openTrackInfo(trackId) - } catch (e) { - // TODO(glue): no openTrackInfo on the Qt bridge yet — the modal - // still opens and shows its loading state. - console.warn("[qbz-qt] track info: no bridge invokable yet") - } + QbzAlbum.openTrackInfo(trackId) open() } @@ -112,9 +103,12 @@ Popup { close() QbzArtist.openArtist(artistId) } - // TODO(qt-bridge): no label view in the Qt port (cards/LabelCard.qml has - // the same hole) — the link is rendered 1:1 and inert. - function openLabel(labelId) {} + function openLabel(labelId) { + if (!labelId || labelId === "") + return + close() + QbzHome.openLabel(labelId) + } // Musician click — consumer #5 of six (contract §2.2). This was an empty // stub: the name hovered like a link and did nothing. // diff --git a/crates/qbz-qt/qml/views/ArtistReleasesView.qml b/crates/qbz-qt/qml/views/ArtistReleasesView.qml index ced288619..8c31efe5b 100644 --- a/crates/qbz-qt/qml/views/ArtistReleasesView.qml +++ b/crates/qbz-qt/qml/views/ArtistReleasesView.qml @@ -37,16 +37,13 @@ // have — QbzOfflinePlaceholder over a hidden body — and `artist_releases_qt:: // open` refuses to navigate here offline in the first place. // -// POC-NOTEs (pre-existing and port-wide, NOT deferrals introduced here): -// - no scroll restore. The .slint restores NavState.scroll-restore under the -// "artist-releases" scope (:110-119); this port has no counterpart to -// NavState.report-scroll / restore-scope on ANY page (DiscoverBrowseView.qml -// :20-21 records the same gap). Half a mechanism would be worse than none. +// Known navigation constraint: // - back/forward re-mounts this view against whatever document is still on // QbzArtist.artistReleasesJson rather than re-fetching, because the port's // nav stack stores no payload (nav_qt.rs:9-23). That is exactly how // `labelreleases` already behaves; the Rust generation guard is what keeps a // late page from painting into the wrong bucket. +// Scroll position itself is restored by the shared ScrollMemory below. import QtQuick import QtQuick.Window diff --git a/crates/qbz-qt/qml/views/ArtistView.qml b/crates/qbz-qt/qml/views/ArtistView.qml index 271ef163c..955fa2db8 100644 --- a/crates/qbz-qt/qml/views/ArtistView.qml +++ b/crates/qbz-qt/qml/views/ArtistView.qml @@ -18,9 +18,9 @@ // MusicBrainz is off in Settings or the artist has no confident MB match, is // simply ABSENT — never an error frame, and nothing is requested. // -// POC-NOTEs: blacklist banner, artist Scene, Create Collection, radio -// engines (dropdown inert), multi-select, the sticky behavior of the JUMP TO -// bar (it scrolls with the page). +// Blacklist banner, Artist Scene, Create Collection, both radio engines, +// multi-select and the sticky JUMP TO bar are live. The remaining artist-page +// deltas are documented beside their data producers in artist_qt.rs. // // "Share" left this list when the seam landed: the ⋯ entry now calls // QbzArtist.share (artist_bridge.rs -> src/share_qt.rs), which copies @@ -625,12 +625,8 @@ Rectangle { // ONE source for the two surfaces the .slint drives off the same // ArtistState.is-blacklisted: the overflow-menu row label // (ArtistPageView.slint:565-567) and the hidden-artist banner (:595, :600). - // artist_qt.rs does not seed the field yet (spec 03 C5 — main.rs:2653-2659 - // does it in the reference), so `artist.isBlacklisted` reads `undefined` - // today and toggleState's `fallback === true` folds that to false; the - // optimistic flip + the `blacklistChanged` settle below make the page - // correct within a visit, and it becomes correct on ENTRY the moment the - // seed lands, with no QML change. + // `artist_qt` seeds the field on entry; the local toggle map provides the + // optimistic edge while `blacklistChanged` settles or rolls it back. readonly property bool artistBlacklisted: toggleState("artistBlacklist", artist.isBlacklisted) function toggleBlacklist() { var aid = artist.id || "" @@ -2442,7 +2438,6 @@ Rectangle { // wrong three tab colours. padH 0 because this Column already // pads 32 — the .slint's own 32px padding lands the strip in the // same place. - // POC-NOTE (unchanged): the bar scrolls with the page; the // The bar itself is an OVERLAY outside this Flickable (see // `jumpBar` after it) so it can pin to the top on scroll. What // stays in the flow is this slot, which reserves exactly its @@ -3142,7 +3137,7 @@ Rectangle { label: modelData.name tooltip: modelData.name iconName: "disc" - // POC-NOTE: no label view yet. + onClicked: QbzHome.openLabel(modelData.id) } } } diff --git a/crates/qbz-qt/qml/views/BlacklistManagerView.qml b/crates/qbz-qt/qml/views/BlacklistManagerView.qml index 3763e1e24..f8de12b49 100644 --- a/crates/qbz-qt/qml/views/BlacklistManagerView.qml +++ b/crates/qbz-qt/qml/views/BlacklistManagerView.qml @@ -24,19 +24,17 @@ // is the global HeaderBar in this port — the same treatment // settings/SettingsView.qml:24-25 already documents for the identical case. // ADR-004 is satisfied by the HeaderBar. -// - `UiFocusState.text-input-focused` (:554) is NOT ported: the port has no -// equivalent guard anywhere and no global keybinding layer to guard (neither -// Main.qml nor AppShell.qml declares a Shortcut). It belongs to the -// port-wide hotkey pass; recorded here as its call site. +// - The old per-view `UiFocusState.text-input-focused` write is unnecessary: +// AppShell/KioskShell compute the active TextInput and pass that gate through +// the app-wide QbzHotkeys dispatcher before any binding is considered. // - The list is a recycling ListView, not a Flickable over every row // (TRACK-RULES §6 — a list that can be large is born windowed), so the // Slint's `list-hover-rows` scrollbar reveal-on-row-hover is dropped: // QbzScrollBar reveals on scroll + gutter hover/press instead, and a // per-row hover counter across a recycling delegate is cost for a cosmetic // cue. -// - No toast host exists in this port, so the reference's failure toasts have -// nowhere to land. Failures log Rust-side and the settled `blacklistChanged` -// republish corrects any optimistic glyph. +// - Mutation feedback uses the app-wide QbzToast host through `toast_qt`; +// the settled `blacklistChanged` republish also corrects optimistic glyphs. // // Nav re-entry: nav_qt::back()/forward() republish `currentView` and run NO // per-view load, so this view calls QbzBlacklist.reload() in diff --git a/crates/qbz-qt/qml/views/DiscoverBrowseView.qml b/crates/qbz-qt/qml/views/DiscoverBrowseView.qml index 40cceeaca..373a069e5 100644 --- a/crates/qbz-qt/qml/views/DiscoverBrowseView.qml +++ b/crates/qbz-qt/qml/views/DiscoverBrowseView.qml @@ -18,9 +18,9 @@ // (HeaderBar.qml:235), so the title starts at x=48 — the 32 + 0 + 16 the // HomeView toolbar already uses (HomeView.qml:744). // -// POC-NOTEs: no scroll restore (the port has no counterpart to -// NavState.report-scroll / restore-scope); `windowed` grid mounting is the -// AlbumCollection sampler rather than the .slint's own. +// Back/forward scroll restore is handled by the shared ScrollMemory below; +// `windowed` grid mounting is the AlbumCollection sampler rather than the +// .slint's own. import QtQuick import QtQuick.Window diff --git a/crates/qbz-qt/qml/views/LabelView.qml b/crates/qbz-qt/qml/views/LabelView.qml index 2a8dc11d9..365e8cc7d 100644 --- a/crates/qbz-qt/qml/views/LabelView.qml +++ b/crates/qbz-qt/qml/views/LabelView.qml @@ -26,9 +26,8 @@ // - NO "In library" tab. The Qt library feed carries no label id, so // `libraryCount` is always 0 and the SegmentedTabBar never mounts (see // src/label_qt.rs). A toggle that switches to an empty tab is worse. -// - Share copies the Qobuz link with no toast: there is no Rust clipboard -// bridge and no toast seam (TrackRow.qml:538-558 documents both). -// - No scroll restore (the port has no NavState.restore-scope counterpart). +// - Share is live through the Rust clipboard bridge and shared toast host. +// - Back/forward scroll restore is handled by ScrollMemory below. // // LOGO FIT — a CONSCIOUS deviation: both .slint label views draw the logo // `image-fit: cover` (LabelPageView.slint:116). This port draws it CONTAIN, diff --git a/crates/qbz-qt/qml/views/LibraryView.qml b/crates/qbz-qt/qml/views/LibraryView.qml index bc20cd4c1..eb066e848 100644 --- a/crates/qbz-qt/qml/views/LibraryView.qml +++ b/crates/qbz-qt/qml/views/LibraryView.qml @@ -17,14 +17,10 @@ // library/LibraryArtistsPanel.qml Artists in SIDEPANEL mode // Each takes `view: root`, the seam views/local/*.qml already uses. // -// Still out of scope, and named rather than implied: -// - Offline: the generic OfflinePlaceholder replica mounts (the Slint -// offline RAIL of playable cached favorites needs the offline cache — -// not wired; PARITY-DEBT #17). -// - The Library "All" local SCOPE ("favorites" vs the whole local library) -// is PARITY-DEBT #6: the pref is read and PRESERVED on disk by -// library_prefs.rs, but neither the Settings row nor the `all` branch of -// the feed exists here yet. +// Remaining delta: while offline, the generic placeholder mounts instead of +// Slint's rail of playable cached favourites. The offline cache itself and +// the Library "All" local scope (favourites vs the whole local library) are +// both live; this is a missing Library presentation, not a missing engine. // // Filter by genre IS wired, on the All / Tracks / Albums toolbars — the three // places the Slint FavoritesView draws it (FavoritesView.slint:609, :652, @@ -1402,9 +1398,9 @@ Rectangle { anchors.rightMargin: 32 selectedCount: root.tracksSelectedCount // The reference's inventory MINUS "Make available offline": - // this port brings up no offline cache at all (src/ - // library_bulk.rs names the check), and a bulk bar must not - // render a control that no-ops. Everything else is wired. + // the cache engine is live, but the Library bulk-download + // action has no bridge seam yet. A bulk bar must not render a + // control that no-ops. Everything else is wired. // "heart-crack" is likewise not in this port's icon set, so // "Remove from Library" uses the same heart + danger pair // views/local/LocalTracksTab.qml already ships. diff --git a/crates/qbz-qt/qml/views/MixView.qml b/crates/qbz-qt/qml/views/MixView.qml index 07ba9b492..59e5cefed 100644 --- a/crates/qbz-qt/qml/views/MixView.qml +++ b/crates/qbz-qt/qml/views/MixView.qml @@ -41,8 +41,7 @@ // - The "Add to playlist" disc (list-plus) is inert in the .slint too — it // carries no `clicked` there (MixView.slint:182-188) — so it is inert // here, dimmed the same way, rather than silently absent. -// - No scroll restore: this port has no NavState.restore-scope counterpart -// (the same note LabelView.qml carries). +// - Back/forward scroll restore is handled by ScrollMemory below. // - CIRCLE ACTION SIZE: the .slint MixView declares a LOCAL CircleAction // fixed at 38px, predating the shared primitives/CircleAction.slint // (44 primary / 32 secondary). This port mounts the shared diff --git a/crates/qbz-qt/qml/views/PlaylistView.qml b/crates/qbz-qt/qml/views/PlaylistView.qml index 4faa4f706..0fe7a510c 100644 --- a/crates/qbz-qt/qml/views/PlaylistView.qml +++ b/crates/qbz-qt/qml/views/PlaylistView.qml @@ -36,9 +36,8 @@ // is coming (the Slint mounts a bare LoadingSpinner), plus a per-item cover // placeholder that clears when the playlist's own cover lands. // -// POC-NOTEs (playlist_qt.rs has the full list): local playlists, -// custom-cover set/clear, multi-select + bulk bar, Suggested Songs, -// offline download, share — not ported. +// Known remaining parity deltas (playlist_qt.rs has the full list): +// Suggested Songs and whole-playlist offline download are not ported. import QtQuick import QtQuick.Controls @@ -370,7 +369,7 @@ Rectangle { Item { width: 1; height: 22 } // Header placeholder — the SHAPE of the header that is coming - // (150px cover + eyebrow/title/description/meta bars + the 7 round + // (150px cover + eyebrow/title/description/meta bars + the 8 round // action buttons), not a spinner. Replaced wholesale by the real // header the moment the document lands. QbzSkeleton { @@ -380,7 +379,7 @@ Rectangle { height: 150 coverSize: 150 coverGap: 24 - actionCount: 7 + actionCount: 8 phase: root.skelPhase } @@ -635,6 +634,12 @@ Rectangle { anchors.verticalCenter: parent.verticalCenter onClicked: QbzBridge.playlistCopy() } + QbzCircleAction { + visible: !root.isLocal + name: "link" + anchors.verticalCenter: parent.verticalCenter + onClicked: QbzBridge.playlistShare(String(root.doc.id || "")) + } QbzCircleAction { visible: root.isOwner name: "pen-line" @@ -662,7 +667,7 @@ Rectangle { onClicked: root.setMultiSelect(!root.multiSelect) } - Item { width: parent.width - 40 - 32 * 7 - 8 * 12 - 220 - 140; height: 1 } + Item { width: parent.width - 40 - 32 * 8 - 9 * 12 - 220 - 140; height: 1 } // In-playlist search. Rectangle { diff --git a/crates/qbz-qt/qml/views/myqbz/MyQbzGridView.qml b/crates/qbz-qt/qml/views/myqbz/MyQbzGridView.qml index 8b5c50291..77524835f 100644 --- a/crates/qbz-qt/qml/views/myqbz/MyQbzGridView.qml +++ b/crates/qbz-qt/qml/views/myqbz/MyQbzGridView.qml @@ -47,9 +47,8 @@ // scrolls, because the body must be a windowed GridView/ListView and // nesting one inside a page Flickable is not an option. Same shape as // views/LibraryView.qml and views/LocalLibraryView.qml. -// 4. Slint's scroll-position restore (NavState.restore-scope / scroll-restore -// / report-scroll, :38-50) has NO counterpart anywhere in this port. Omitted -// rather than reinvented as a MyQBZ-only mechanism. +// 4. Scroll-position restore uses the shared ScrollMemory instances below, +// separately for the grid and list bodies. // 5. Slint reads `MyQbzState.loading` in neither grid; the cardGrid skeleton // below is the same deliberate improvement LibraryView.qml:1163-1225 // documents. The empty state stays gated on `!loading`, and the skeleton is diff --git a/crates/qbz-qt/src/album_bridge.rs b/crates/qbz-qt/src/album_bridge.rs index 5e4c06f1b..56176fec0 100644 --- a/crates/qbz-qt/src/album_bridge.rs +++ b/crates/qbz-qt/src/album_bridge.rs @@ -89,6 +89,12 @@ pub mod qbz_album_bridge { fn share_qobuz_link(self: Pin<&mut QbzAlbum>, album_id: QString); #[qinvokable] fn share_album_link(self: Pin<&mut QbzAlbum>, album_id: QString); + /// Track context-menu Share rows. The universal-link arm fetches the + /// track's ISRC before resolving it through Deezer/Song.link. + #[qinvokable] + fn share_track_qobuz(self: Pin<&mut QbzAlbum>, track_id: QString); + #[qinvokable] + fn share_track_link(self: Pin<&mut QbzAlbum>, track_id: QString); /// Header info button: fetch + publish the Album Info (credits / /// review) document (album_info_qt.rs). #[qinvokable] @@ -197,6 +203,12 @@ impl qbz_album_bridge::QbzAlbum { pub fn share_album_link(self: Pin<&mut Self>, album_id: QString) { crate::share_qt::share_album_link(album_id.to_string()); } + pub fn share_track_qobuz(self: Pin<&mut Self>, track_id: QString) { + crate::share_qt::share_track_qobuz(track_id.to_string()); + } + pub fn share_track_link(self: Pin<&mut Self>, track_id: QString) { + crate::share_qt::share_track_link(track_id.to_string()); + } pub fn open_album_info(self: Pin<&mut Self>, album_id: QString) { crate::album_info_qt::open(album_id.to_string()); } diff --git a/crates/qbz-qt/src/album_qt.rs b/crates/qbz-qt/src/album_qt.rs index b9d4b99d7..a350e3e05 100644 --- a/crates/qbz-qt/src/album_qt.rs +++ b/crates/qbz-qt/src/album_qt.rs @@ -19,14 +19,9 @@ //! an empty result produces. Late replies are generation-guarded, so a slow //! Last.fm answer for album A can never paint onto album B. //! -//! POC-NOTEs: -//! - Multi-select + bulk bar and the offline download column: out of scope -//! (inert stubs where visible). -//! - Blacklist filtering on carousels: skipped (store not open). -//! - The album-header-gradient atmosphere: not wired (appearance pref). -//! - The Last.fm "similar albums" second suggestions row needs the -//! `qbz-external-reco` engine, which is not a dependency of this crate — -//! see the GLUE note in the handoff report. NOT faked here. +//! Known parity delta: artist/album blacklist filtering is not yet applied to +//! the three recommendation carousels. Multi-select, offline actions, the +//! header atmosphere and the Last.fm similar-albums row are all live. use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; diff --git a/crates/qbz-qt/src/artist_blacklist.rs b/crates/qbz-qt/src/artist_blacklist.rs index 3a8fcaff8..5b4d1eca5 100644 --- a/crates/qbz-qt/src/artist_blacklist.rs +++ b/crates/qbz-qt/src/artist_blacklist.rs @@ -120,7 +120,6 @@ pub fn is_album_blacklisted(album_id: &str) -> bool { /// grids that map an `AlbumCard`-like row rather than a typed `Album`. // Consumed by the data-layer filter slice (spec 03 §9.2, items F5/F9/F11/F14), // which lands separately from the manager. -#[allow(dead_code)] pub fn card_blacklisted(album_id: &str, artist_id: &str) -> bool { is_album_blacklisted(album_id) || is_blacklisted_id_str(artist_id) } @@ -148,7 +147,6 @@ pub fn card_blacklisted(album_id: &str, artist_id: &str) -> bool { /// mutation, the mutation site re-runs that controller's existing reload path. /// There is intentionally no global listener/observer. // Consumed by the data-layer filter slice (spec 03 §9.2, items F7/F12/F15-F18). -#[allow(dead_code)] pub fn stamp_row(source: &str, artist_ids: &[&str], album_id: Option<&str>) -> bool { // Local / Plex / ephemeral rows are protected — never blacklisted. if source != "qobuz" { @@ -174,7 +172,6 @@ pub fn stamp_row(source: &str, artist_ids: &[&str], album_id: Option<&str>) -> b /// blacklisted drops the track. /// - Missing / `None` ids => fail-open (`false`), so id-less tracks always play. // Consumed by the data-layer filter slice (spec 03 §9.2, item F18 — playback). -#[allow(dead_code)] pub fn is_track_blacklisted( source: &str, performer_id: Option, @@ -201,7 +198,6 @@ pub fn is_enabled() -> bool { /// reflects the persisted rows (ignores the enabled flag — callers gate on /// [`is_enabled`] separately). // Consumed by the data-layer filter slice (spec 03 §9.2, items F1/F3/F6/F8...). -#[allow(dead_code)] pub fn ids_snapshot() -> HashSet { with_service(HashSet::new(), |s| { s.get_all() @@ -226,7 +222,6 @@ pub fn count() -> usize { /// filtering. Empty when no session is bound. Reflects persisted rows (ignores /// the enabled flag — callers gate on [`is_enabled`] separately). // Consumed by the data-layer filter slice (spec 03 §9.2, items F1/F3/F6/F8...). -#[allow(dead_code)] pub fn album_ids_snapshot() -> HashSet { with_service(HashSet::new(), |s| { s.get_all_albums() diff --git a/crates/qbz-qt/src/artist_qt.rs b/crates/qbz-qt/src/artist_qt.rs index 6b8570dce..d0261b50a 100644 --- a/crates/qbz-qt/src/artist_qt.rs +++ b/crates/qbz-qt/src/artist_qt.rs @@ -15,9 +15,8 @@ //! all and the Origin / Relationships / You-may-also-like sections are simply //! absent from the sidebar (never an error, never an empty frame). //! -//! POC-NOTEs: -//! - Blacklist banner/filter, "In library" PLAYLISTS sub-lists, jump-tab -//! scroll-tracking: out of scope. +//! Known delta: the "In library" tab has album/track content but not the +//! reference's playlist sub-list. //! - `is_following` seeds from `library_qt::is_favorite` — the favourite-id //! cache first (the reference's `fav_cache`, now ported as `fav_cache_qt`), //! the phase-5 library feed second. It used to read the feed ALONE, which is @@ -25,12 +24,11 @@ //! artists the user follows, and the toggle then re-ADDED the follow. //! - Discovery runs with EMPTY `dismissed_per_tag` / `known_artists` //! callbacks: the Slint app feeds those from `discovery_dismiss` + -//! `play_history` + `reco`, none of which this POC brings up. That is the +//! `play_history` + `reco`; this path still uses empty callbacks. That is the //! same default a first-run Slint profile lands on (no exclusion applied), //! not a fabricated one. -//! - MB Origin location is rendered as plain text: the clickable form opens -//! ArtistsByLocationView, which this POC has no port of, so the affordance -//! is left out rather than dead. +//! - The blacklist banner, Artist Scene/location navigation, collection +//! builder and jump-scroll tracking are live in `ArtistView.qml`. use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -1800,7 +1798,7 @@ fn format_period(begin: Option<&str>, end: Option<&str>) -> String { /// /// The `dismissed_per_tag` / `known_artists` callbacks are EMPTY here: the /// Slint app feeds them from its `discovery_dismiss`, `play_history` and -/// `reco` stores, none of which this POC opens. Empty = no exclusion, which +/// `reco` stores; this path does not consult them yet. Empty = no exclusion, which /// is exactly what a first-run Slint profile does. async fn load_mb_discovery( runtime: &Arc>, diff --git a/crates/qbz-qt/src/artist_releases_qt.rs b/crates/qbz-qt/src/artist_releases_qt.rs index 6f47f22be..cabe93c12 100644 --- a/crates/qbz-qt/src/artist_releases_qt.rs +++ b/crates/qbz-qt/src/artist_releases_qt.rs @@ -347,9 +347,7 @@ pub fn retry() { /// same reason `myqbz_qt::republish_all` is called unconditionally from /// `apply_language`. A no-op on an empty document (nothing has been opened). /// -/// This is wired (main.rs `apply_language`), unlike -/// `label_qt::republish_for_language`, which is `#[allow(dead_code)]` and whose -/// own doc comment says "GLUE NEEDED: nothing calls this yet". +/// Wired from `main.rs::apply_language` alongside Browse and Label republish. pub fn republish() { let has_page = with_state(|s| { if s.artist_id.is_empty() { diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs index 2496518ea..6a0ad18eb 100644 --- a/crates/qbz-qt/src/bridge.rs +++ b/crates/qbz-qt/src/bridge.rs @@ -1,4 +1,4 @@ -//! The single Qt-side bridge object for the POC. +//! The shared Qt-side bridge object for cross-domain shell state. //! //! One `QbzBridge` QObject is registered as a QML SINGLETON (`QbzBridge.*` //! in QML). All session/login/offline/shell state the QML needs lives in @@ -141,6 +141,9 @@ pub mod qbz_bridge { /// "Copy to your library" (foreign playlists). #[qinvokable] fn playlist_copy(self: Pin<&mut QbzBridge>); + /// Copy the public Qobuz playlist URL. Hidden for local playlists. + #[qinvokable] + fn playlist_share(self: Pin<&mut QbzBridge>, playlist_id: QString); #[qinvokable] fn playlist_rename(self: Pin<&mut QbzBridge>, name: QString); #[qinvokable] @@ -335,6 +338,10 @@ impl qbz_bridge::QbzBridge { crate::playlist_copy(); } + pub fn playlist_share(self: Pin<&mut Self>, playlist_id: QString) { + crate::share_qt::share_playlist(playlist_id.to_string()); + } + pub fn playlist_rename(self: Pin<&mut Self>, name: QString) { crate::playlist_rename(name.to_string()); } diff --git a/crates/qbz-qt/src/browse_qt.rs b/crates/qbz-qt/src/browse_qt.rs index 014bbbddf..fbaf167a0 100644 --- a/crates/qbz-qt/src/browse_qt.rs +++ b/crates/qbz-qt/src/browse_qt.rs @@ -22,15 +22,10 @@ //! Split out of `home_qt.rs` deliberately: that module is already ~1.2 k //! lines and these are four independent controllers, not more Home mapping. //! -//! POC-NOTEs (deltas vs the Slint controllers, each deliberate): -//! - Blacklist filtering: `crate::artist_blacklist` does not exist in this -//! port (see the phase-1 skip documented in `home_qt.rs`), so the -//! `album_blacklisted` drop the Slint applies on the discover pages is -//! inherited as a no-op. Playlists are unfiltered in Slint too. -//! - Scroll restore: the Slint pages each call `NavState.report-scroll` and -//! re-apply `scroll-restore` on back/forward. The Qt port has NO -//! scroll-restore mechanism at all (`nav_qt` stores view ids only), so -//! these pages do not restore scroll. Dropped knowingly, not missed. +//! Known delta: the blacklist store is live, but the album drop the Slint +//! discover pages apply is not yet folded into these result lists. Playlists +//! are unfiltered in the reference too. Back/forward scroll restore is live +//! through `ScrollMemory.qml` + `nav_qt`. //! - `nav_qt` stores view IDs, so back-to-`discoverbrowse` re-mounts //! whatever document is currently published (same limitation the album / //! artist routes already have). @@ -864,9 +859,8 @@ fn load_history(generation: u64) { /// and the playlist row subtitles (`tf()` plurals) — everything else is /// `@tr` on the QML side and re-evaluates on `trRev`. /// -/// GLUE NEEDED: nothing calls this yet — `main.rs::apply_language` only -/// republishes "album" / "artist" off `LAST_DETAIL`. See the report. -#[allow(dead_code)] +/// Called by `main.rs::apply_language`; `trRev` cannot retranslate strings +/// that already arrived inside a JSON document. pub fn republish_for_language() { { if let Ok(mut s) = PLAYLISTS.lock() { diff --git a/crates/qbz-qt/src/cast_qt.rs b/crates/qbz-qt/src/cast_qt.rs index a8466fc49..13a24114d 100644 --- a/crates/qbz-qt/src/cast_qt.rs +++ b/crates/qbz-qt/src/cast_qt.rs @@ -165,9 +165,7 @@ struct CastInner { media_server: Option, // Playback mirror. `current_track_id` is session bookkeeping (which track // the renderer currently holds) kept 1:1 with the Slint service; nothing - // reads it yet, and the allow keeps that honest instead of dropping a - // field the transport work will want. - #[allow(dead_code)] + // reads it yet; it is transport session bookkeeping. current_track_id: Option, is_playing: bool, // Track-end one-shot latch (reset on PLAYING). @@ -758,7 +756,6 @@ impl CastService { // Ok(true) = handled. Call sites live on the playback path — see the // report's GLUE NEEDED for `playback_qt.rs`. - #[allow(dead_code)] // wired by playback_qt::toggle_play — see GLUE pub(crate) async fn toggle_play_if_cast(&self) -> Result { let (proto, playing) = { let inner = self.inner.lock().await; @@ -781,7 +778,6 @@ impl CastService { /// derive the absolute position from the local engine while casting (it /// is stopped, so its duration reads 0 and every drag would restart the /// track) — resolve the duration from the catalog metadata instead. - #[allow(dead_code)] // wired by playback_qt::seek_frac — see GLUE pub(crate) async fn seek_fraction_if_cast(&self, fraction: f64) -> Result { if !self.is_casting().await { return Ok(false); @@ -802,7 +798,6 @@ impl CastService { Ok(true) } - #[allow(dead_code)] // wired by playback_qt::set_volume — see GLUE pub(crate) async fn set_volume_if_cast(&self, volume: f32) -> Result { let proto = { let inner = self.inner.lock().await; @@ -864,7 +859,6 @@ impl CastService { Ok(()) } - #[allow(dead_code)] // only reached through toggle_play_if_cast — see GLUE async fn play_renderer(&self, proto: CastProtocol) -> Result<(), String> { match proto { CastProtocol::Chromecast => { @@ -883,7 +877,6 @@ impl CastService { Ok(()) } - #[allow(dead_code)] // only reached through toggle_play_if_cast — see GLUE async fn pause_renderer(&self, proto: CastProtocol) -> Result<(), String> { match proto { CastProtocol::Chromecast => { @@ -1147,7 +1140,6 @@ impl CastService { /// Tear everything down: stop the renderer, abort the poll, drop discovery /// and the media server, so a cast device does not keep playing after /// logout or exit (Tauri parity, #32/#33). - #[allow(dead_code)] // wired by do_logout / the close handler — see GLUE pub(crate) async fn shutdown(&self) { let _ = self.stop_renderer().await; let mut inner = self.inner.lock().await; @@ -1473,14 +1465,12 @@ pub(crate) fn push_cap_options() { // --------------------------------------------------------------------------- /// True while a renderer owns transport. -#[allow(dead_code)] // wired by playback_qt / the poll pump — see GLUE pub(crate) async fn is_casting() -> bool { service().is_casting().await } /// Route the queue's CURRENT track to the connected renderer instead of /// opening a local stream. Ok(false) = not casting, take the local path. -#[allow(dead_code)] // wired by playback_qt::play_queue_track — see GLUE pub(crate) async fn play_current_if_cast(runtime: &Runtime) -> bool { let svc = service(); if !svc.is_casting().await { diff --git a/crates/qbz-qt/src/deep_link_qt.rs b/crates/qbz-qt/src/deep_link_qt.rs new file mode 100644 index 000000000..30dd95a73 --- /dev/null +++ b/crates/qbz-qt/src/deep_link_qt.rs @@ -0,0 +1,96 @@ +//! Launcher deep links shared by cold starts and the Linux single-instance +//! D-Bus handoff. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; + +static PENDING: Mutex> = Mutex::new(None); +static ONLINE_SESSION: AtomicBool = AtomicBool::new(false); + +/// The native Qobuz URL shapes accepted by the desktop launcher files. +pub(crate) fn is_qobuz_link(arg: &str) -> bool { + arg.starts_with("qobuzapp://") + || arg.starts_with("https://play.qobuz.com/") + || arg.starts_with("http://play.qobuz.com/") + || arg.starts_with("https://open.qobuz.com/") + || arg.starts_with("http://open.qobuz.com/") +} + +fn select_link(args: &[String]) -> Option { + args.iter().find(|arg| is_qobuz_link(arg)).cloned() +} + +/// Capture argv before the single-instance decision so a duplicate process +/// can forward its URL to the owner of the bus name. +pub(crate) fn capture_argv() { + let args: Vec = std::env::args().skip(1).collect(); + if let Some(url) = select_link(&args) { + log::info!( + "[qbz-qt] deep link: captured {}", + url.split('?').next().unwrap_or(&url) + ); + stash(url); + } +} + +/// Newest launch intent wins, matching the former frontend. +pub(crate) fn stash(url: String) { + if let Ok(mut pending) = PENDING.lock() { + *pending = Some(url); + } +} + +pub(crate) fn take_pending() -> Option { + PENDING.lock().ok().and_then(|mut pending| pending.take()) +} + +/// Bind/unbind the only context in which a Qobuz route can be fetched. +pub(crate) fn set_online_session(active: bool) { + ONLINE_SESSION.store(active, Ordering::SeqCst); + if active { + drain_pending(); + } +} + +/// Resolve the pending link once an authenticated shell is available. +pub(crate) fn drain_pending() { + if !ONLINE_SESSION.load(Ordering::SeqCst) { + return; + } + let Some(url) = take_pending() else { + return; + }; + log::info!( + "[qbz-qt] deep link: resolving {}", + url.split('?').next().unwrap_or(&url) + ); + crate::link_resolver_qt::resolve_deep_link(url); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_only_launcher_qobuz_shapes() { + assert!(is_qobuz_link("https://open.qobuz.com/album/a")); + assert!(is_qobuz_link("http://play.qobuz.com/track/1")); + assert!(is_qobuz_link("qobuzapp://artist/1")); + assert!(!is_qobuz_link("https://open.qobuz.com.evil.test/album/a")); + assert!(!is_qobuz_link("https://spotify.com/track/1")); + assert!(!is_qobuz_link("qbz://album/a")); + } + + #[test] + fn first_matching_argument_wins() { + let args = vec![ + "--flag".to_string(), + "https://open.qobuz.com/album/first".to_string(), + "qobuzapp://album/second".to_string(), + ]; + assert_eq!( + select_link(&args), + Some("https://open.qobuz.com/album/first".to_string()) + ); + } +} diff --git a/crates/qbz-qt/src/diagnostics_qt.rs b/crates/qbz-qt/src/diagnostics_qt.rs index 34ca7e4f5..f65de1f6f 100644 --- a/crates/qbz-qt/src/diagnostics_qt.rs +++ b/crates/qbz-qt/src/diagnostics_qt.rs @@ -18,12 +18,8 @@ //! ── WHERE THE CODE LIVES, AND WHY IT IS NOT SHARED ──────────────────────── //! //! The producers below (`gather`, the row builders, `collect_output_sinks`, -//! `active_sink_format`, `redact_id_like`) DUPLICATE their twins in -//! `crates/qbz/src/diagnostics.rs`. That is deliberate, not an oversight: that -//! crate is the frozen Slint frontend and it pulls `qbz-ui`, a single ~1.6 M-line -//! compilation unit that peaks 20-30 GB — so moving the functions there into a -//! shared crate could not be compile-checked without a build that freezes this -//! machine. The Slint copy dies with that tree in 2.1. Everything that ALREADY +//! `active_sink_format`, `redact_id_like`) originated in the retired frontend +//! but are now the sole application implementation. Everything that already //! lives in a shared crate is called, not copied: `qbz_app::diagnostics` //! (`runtime_diagnostics` / `system_info` / `detect_graphics_runtime`), //! `qbz_audio::output_sinks::list_output_sinks`, and Qt's own `renderer_qt` / diff --git a/crates/qbz-qt/src/discover_config_qt.rs b/crates/qbz-qt/src/discover_config_qt.rs index 55408b619..1804f2f10 100644 --- a/crates/qbz-qt/src/discover_config_qt.rs +++ b/crates/qbz-qt/src/discover_config_qt.rs @@ -14,17 +14,9 @@ //! `{ tab, rows: [{ id, label, enabled }], enabled, total }` — parsed by //! `qml/controls/DiscoverConfigModal.qml`. //! -//! POC-NOTEs vs the Slint controller: -//! - Rows are filtered to the sections this port can actually RENDER -//! (`home_qt::renderable_ids`). The Slint modal lists every pref entry -//! because Slint implements every rail; here a reco-engine / radio / -//! spotlight row would be a toggle that changes nothing, which is exactly -//! the defect class this port removes. -//! - The Recommendations tab's arm of the Slint modal (explainer + cache -//! window + "Refresh now") is NOT ported: it drives -//! `ExternalRecoState`/`ExternalRecoActions`, and the external reco engine -//! is out of scope. The toolbar gear is DISABLED on that tab instead of -//! opening an empty modal (HomeView.qml). +//! Rows are filtered to the sections the active tab can render. The +//! Recommendations tab has no orderable rows and uses the modal's dedicated +//! explainer, cache-window and "Refresh now" arm instead. use cxx_qt_lib::QString; use qbz_app::settings::discover_prefs::{ diff --git a/crates/qbz-qt/src/foryou_qt.rs b/crates/qbz-qt/src/foryou_qt.rs index f41aa93e8..eca9c4d37 100644 --- a/crates/qbz-qt/src/foryou_qt.rs +++ b/crates/qbz-qt/src/foryou_qt.rs @@ -48,8 +48,8 @@ //! the store is cold. This port has no `qbz-reco` dependency, so it is the //! FALLBACK path only — the same delta `home_qt` already documents for //! "Rediscover Your Library". -//! - No artist blacklist store in this port (`artist_blacklist` is Slint-side), -//! so the mix rows carry no `isBlacklisted` grey-out. +//! - The shared artist blacklist store is live, but mix-row mapping does not +//! yet consult it, so the rows carry no `isBlacklisted` grey-out. //! - The mix page has no multi-select bar: `qml/rows/TrackRow.qml` has no //! multi-select arm and two of the Slint bar's seven actions have no bridge //! seam at all — the same call `LabelView.qml` already documents. The diff --git a/crates/qbz-qt/src/genre_filter_qt.rs b/crates/qbz-qt/src/genre_filter_qt.rs index d85a16415..43cbded99 100644 --- a/crates/qbz-qt/src/genre_filter_qt.rs +++ b/crates/qbz-qt/src/genre_filter_qt.rs @@ -22,15 +22,15 @@ //! - "library-all" -> `names.library-all` feeds the QML feed derive //! (`library_all.rs::derive`'s genre arm, 1:1). //! -//! POC-NOTEs vs the Slint controller: +//! Qt adaptation vs the Slint controller: //! - The parent list loads LAZILY (first popup open) instead of at shell -//! entry: the port has no shell-entry hook it may edit. The persisted +//! entry, avoiding an eager catalog request. The persisted //! SELECTION is read eagerly (no network), so a remembered filter applies //! to the very first discover fetch exactly like Slint. //! - The "favorites" context does not exist here: the Slint FavoritesView //! draws its genre button only on the Library "All" tab //! (FavoritesView.slint:864, `context: "library-all"`), and so does this -//! port. The per-context store handles it the day a favorites button lands. +//! frontend. The per-context store can support another context if one lands. use std::collections::{HashMap, HashSet}; use std::path::PathBuf; diff --git a/crates/qbz-qt/src/home_bridge.rs b/crates/qbz-qt/src/home_bridge.rs index ac6d7d541..9162f4652 100644 --- a/crates/qbz-qt/src/home_bridge.rs +++ b/crates/qbz-qt/src/home_bridge.rs @@ -35,7 +35,7 @@ pub mod qbz_home { // --- Discover > Home --------------------------------------------- #[qproperty(bool, home_loading)] #[qproperty(QString, home_error)] - // POC-NOTE: the brief's QVariantList-of-QVariantMap shape is not + // Transport note: QVariantList-of-QVariantMap is not // expressible in cxx-qt-lib 0.7.3 (no QVariantValue impls for // QMap/QList — a QVariant can't hold a nested map/list), so the // sections travel as ONE JSON document; QML JSON.parse()s it into diff --git a/crates/qbz-qt/src/home_qt.rs b/crates/qbz-qt/src/home_qt.rs index f8faa8641..2d451f720 100644 --- a/crates/qbz-qt/src/home_qt.rs +++ b/crates/qbz-qt/src/home_qt.rs @@ -6,18 +6,15 @@ //! JSON document (`homeSectionsJson`) — see the POC-NOTE in //! `publish_sections` for why not QVariantList-of-QVariantMap. //! -//! POC-NOTEs (skipped vs the Slint controller): -//! - Artist/album blacklist filtering (T8): the blacklist store is not -//! opened in this POC (phase-1 skip), so no rows are dropped. +//! Known parity deltas: +//! - Artist/album blacklist filtering: the shared store is live, but Home's +//! section assembly does not yet consult it, so no rows are dropped here. //! - Reco-scored taste ordering of favorite albums (reco store skipped): //! favorites render in plain favorite order, and Rediscover falls back to //! the local "not in the recently-played window" heuristic (the same //! fallback the Slint build uses while its reco store is cold). -//! - Radio Stations (For You): the port has no album-radio invokable, so the -//! rail is NOT built — a tile that starts nothing is worse than an absent -//! rail. Artist Spotlight likewise has no ported panel. -//! - Qobuz Mixes renders as the four static navigation tiles (the mix DETAIL -//! views are out of scope — tiles inert). +//! - Radio Stations, Artist Spotlight and the four Qobuz Mix detail views are +//! live in `foryou_qt`; this module only provides their ordering slots. //! - The "View all" full-list pages are LIVE (see `browse_qt`): the generic //! album carousels open DiscoverBrowse for their endpoint, and the three //! local rails (Qobuz Playlists / Recently Played Albums / Most Played @@ -1684,8 +1681,8 @@ pub(crate) fn quality_detail_from_parts( } // --------------------------------------------------------------------------- -// Personalized rails — ported from foryou.rs (live-session only; the reco / -// blacklist stores are skipped, see module docs). +// Personalized rails — live-session data; reco taste ordering and Home-level +// blacklist filtering remain the two documented deltas above. // --------------------------------------------------------------------------- /// Flat-Album card mapping (foryou.rs `map_album`). @@ -1917,8 +1914,8 @@ where } } -/// "Release Watch" — `/release/watch` artists, capped 18 (blacklist filter -/// skipped — the store is not open in this POC). +/// "Release Watch" — `/release/watch` artists, capped 18. Home-level blacklist +/// filtering is a known parity delta. async fn fetch_release_watch(runtime: &Arc>) -> Vec where A: FrontendAdapter + Send + Sync + 'static, diff --git a/crates/qbz-qt/src/hotkeys_bridge.rs b/crates/qbz-qt/src/hotkeys_bridge.rs index bf0355b82..f56af7923 100644 --- a/crates/qbz-qt/src/hotkeys_bridge.rs +++ b/crates/qbz-qt/src/hotkeys_bridge.rs @@ -321,9 +321,7 @@ impl qbz_hotkeys::QbzHotkeys { } /// The §2 dispatch table (keybindings.rs:502-531 `run_action`). true = - /// consumed. ONE no-op seam is left and it is CONTRACTUAL (K3, trap 10): - /// ui.openLink (LinkResolver not ported, HeaderBar.qml:23 — activates when - /// it lands). The mini.* rows still no-op here — they belong to the mini + /// consumed. The mini.* rows still no-op here — they belong to the mini /// window's own dispatcher (mini_bridge::key_pressed) and are kept in this /// table for binding-file compat + the cheatsheet. **ui.miniPlayer is no /// longer a seam**: it toggles the Qt miniplayer (2026-08-03 @@ -347,11 +345,7 @@ impl qbz_hotkeys::QbzHotkeys { self.as_mut().set_cheatsheet_open(true); publish_groups(self.as_mut()); } - "ui.openLink" => { - log::debug!( - "[qbz-qt] hotkeys: ui.openLink — no-op seam, LinkResolver not ported (K3)" - ); - } + "ui.openLink" => crate::link_resolver_bridge::open_modal(), // Shift+M — the reference's `toggle_miniplayer()` // (keybindings.rs:547-558). Its gamescope guard is NOT repeated // here: it lives in `QbzMini::enter`, so this hotkey and the view @@ -375,9 +369,7 @@ impl qbz_hotkeys::QbzHotkeys { /// any fired action, keybindings.rs:498-499). fn handle_escape(mut self: Pin<&mut Self>) -> bool { let state = EscapeState { - // ABSENT-BY-GAP (§1.2.1): the LinkResolver is not ported - // (HeaderBar.qml:23) — the arm stays in the ORDER, never taken. - link_resolver_open: false, + link_resolver_open: crate::link_resolver_bridge::is_open(), customize_open: *self.customize_open(), cheatsheet_open: *self.cheatsheet_open(), // Sampled from the OWNER of the state, like cortinilla/immersive @@ -402,7 +394,8 @@ impl qbz_hotkeys::QbzHotkeys { crate::shell_bridge::ui(|s| s.exit_multi_select()); } EscapeTarget::Queue => crate::shell_bridge::ui(|s| s.toggle_queue()), - EscapeTarget::LinkResolver | EscapeTarget::None => {} + EscapeTarget::LinkResolver => crate::link_resolver_bridge::close_modal(), + EscapeTarget::None => {} } true } diff --git a/crates/qbz-qt/src/hotkeys_qt.rs b/crates/qbz-qt/src/hotkeys_qt.rs index 1b96bae7b..fae1a73e0 100644 --- a/crates/qbz-qt/src/hotkeys_qt.rs +++ b/crates/qbz-qt/src/hotkeys_qt.rs @@ -476,14 +476,6 @@ pub fn active_bindings_with(overrides: &BTreeMap) -> BTreeMap BTreeMap { - active_bindings_with(&load_overrides()) -} - fn action_for_shortcut<'a>( shortcut: &str, bindings: &'a BTreeMap, @@ -765,9 +757,7 @@ pub fn is_ctrl_a(key: i32, modifiers: i32, text: &str) -> bool { #[derive(Clone, Copy, Default, Debug)] pub struct EscapeState { - /// ABSENT-BY-GAP (contract §1.2.1): the LinkResolver is not ported - /// (`HeaderBar.qml:23`) — always false; the arm is kept so the ORDER is - /// the Slint one when the surface lands. + /// App-wide Open Music Link modal; first in the Escape priority stack. pub link_resolver_open: bool, pub customize_open: bool, pub cheatsheet_open: bool, diff --git a/crates/qbz-qt/src/immersive_bridge.rs b/crates/qbz-qt/src/immersive_bridge.rs index a313c5065..2f7551588 100644 --- a/crates/qbz-qt/src/immersive_bridge.rs +++ b/crates/qbz-qt/src/immersive_bridge.rs @@ -258,26 +258,6 @@ fn focus_mode_for_tier(view_mode: i32, mode: i32, gpu_tier: bool) -> i32 { /// the §3.3a viz keep-alive on both edges. fn apply_open(mut this: Pin<&mut QbzImmersive>, value: bool) { use cxx_qt::CxxQtType as _; - // The KIOSK shell has no Immersive surface yet: its mount - // (`qml/shell/KioskShell.qml`) is the sanctioned empty Loader of the - // 2026-08-02 kiosk-port contract (§4.6 / ruling R2), waiting for the - // Immersive task. Opening over it produces a reachable DEAD STATE — the - // kiosk root rejects every arrow, Enter and Esc while `open` is true - // (§7.11), so the whole zone model freezes against a surface that renders - // nothing. - // - // The visualizer TILE already declines to open it for exactly this reason. - // The guard belongs HERE rather than at that call site because this is the - // funnel every entry passes through, and Shift+I — the rebindable - // `ui.focusMode` action, which reaches the dispatcher from the kiosk root - // like any other non-nav key — proved the tile is not the only door. - // - // Refuse to OPEN, never to close: a session that somehow got in must - // always be able to get out. - if value && crate::kiosk_profile_qt::active() { - log::debug!("[qbz-qt] immersive open refused: the kiosk shell has no immersive surface"); - return; - } if this.open == value { return; } diff --git a/crates/qbz-qt/src/label_qt.rs b/crates/qbz-qt/src/label_qt.rs index ebef64d2a..a869cd757 100644 --- a/crates/qbz-qt/src/label_qt.rs +++ b/crates/qbz-qt/src/label_qt.rs @@ -16,11 +16,9 @@ //! library feed carries no label id). `libraryCount` is therefore always //! 0, the catalog/library `SegmentedTabBar` never mounts, and the library //! branch is absent — rather than a toggle that switches to an empty tab. -//! - **Popular Tracks multi-select**: `qml/rows/TrackRow.qml` has no -//! multi-select arm, and two of the Slint bar's seven actions -//! (`add-to-playlist`, `add-to-mixtape`) have no bridge seam at all. The -//! select disc renders DIMMED and inert; the `MultiSelectBar` is not -//! ported. See the view's header note. +//! - **Popular Tracks multi-select**: the shared row and destination bridges +//! are live, but this page does not yet own a selection model or mount the +//! bulk bar. See the view's header note. //! - **Blacklist**: the premise of this note ("`crate::artist_blacklist` does //! not exist in this port") is DEAD — the module is bound at `auth_qt.rs` //! and the QUEUE is filtered since PARITY-DEBT #1. What is still missing is @@ -36,8 +34,7 @@ //! copied link was dead on arrival (owner-reported 2026-08-02). The host is //! per-entity, not per-app: track/album on `open.`, artist/playlist/label on //! `play.` — which is exactly why the URL builders live in one place now. -//! - **Scroll restore**: no Qt counterpart exists (`nav_qt` stores view ids -//! only), so `report-scroll` / `sr-restore` are not ported. +//! - **Scroll restore** is live through the shared `ScrollMemory.qml` seam. use std::collections::{HashMap, HashSet}; use std::sync::Mutex; @@ -952,9 +949,8 @@ fn sort_cards(items: &mut [HomeCard], sort: &str) { /// Re-derive the Rust-built strings on both label documents after a language /// change: the JUMP TO tab labels and the "Unknown" group bucket. /// -/// GLUE NEEDED: nothing calls this yet — `main.rs::apply_language` only -/// republishes "album" / "artist" off `LAST_DETAIL`. See the report. -#[allow(dead_code)] +/// Called by `main.rs::apply_language`; `trRev` cannot retranslate strings +/// that already arrived inside a JSON document. pub fn republish_for_language() { { if let Ok(mut s) = LABEL.lock() { diff --git a/crates/qbz-qt/src/link_resolver_bridge.rs b/crates/qbz-qt/src/link_resolver_bridge.rs new file mode 100644 index 000000000..3a0a0942a --- /dev/null +++ b/crates/qbz-qt/src/link_resolver_bridge.rs @@ -0,0 +1,184 @@ +//! QbzLink — state and actions for the app-wide "Open Music Link" modal. + +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; + +use cxx_qt::CxxQtThread; +use cxx_qt::Threading as _; +use cxx_qt_lib::QString; + +#[cxx_qt::bridge] +pub mod qbz_link { + extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + } + + #[auto_cxx_name] + extern "RustQt" { + #[qobject] + #[qml_element] + #[qml_singleton] + #[qproperty(bool, modal_open)] + #[qproperty(bool, resolving)] + #[qproperty(QString, url)] + #[qproperty(QString, platform)] + #[qproperty(QString, error)] + #[qproperty(bool, playlist_detected)] + #[qproperty(QString, playlist_provider)] + type QbzLink = super::QbzLinkRust; + + #[qinvokable] + fn boot(self: Pin<&mut QbzLink>); + #[qinvokable] + fn show(self: Pin<&mut QbzLink>); + #[qinvokable] + fn close(self: Pin<&mut QbzLink>); + #[qinvokable] + fn url_edited(self: Pin<&mut QbzLink>, url: QString); + #[qinvokable] + fn submit(self: Pin<&mut QbzLink>, url: QString); + #[qinvokable] + fn open_importer(self: Pin<&mut QbzLink>); + } + + impl cxx_qt::Threading for QbzLink {} +} + +use qbz_link::QbzLink; + +pub struct QbzLinkRust { + modal_open: bool, + resolving: bool, + url: QString, + platform: QString, + error: QString, + playlist_detected: bool, + playlist_provider: QString, +} + +impl Default for QbzLinkRust { + fn default() -> Self { + Self { + modal_open: false, + resolving: false, + url: QString::default(), + platform: QString::default(), + error: QString::default(), + playlist_detected: false, + playlist_provider: QString::default(), + } + } +} + +static QT_THREAD: OnceLock> = OnceLock::new(); +static OPEN: AtomicBool = AtomicBool::new(false); + +pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzLink>) + Send + 'static) { + if let Some(thread) = QT_THREAD.get() { + let _ = thread.queue(f); + } +} + +pub(crate) fn is_open() -> bool { + OPEN.load(Ordering::SeqCst) +} + +pub(crate) fn open_modal() { + OPEN.store(true, Ordering::SeqCst); + ui(reset_open); +} + +pub(crate) fn close_modal() { + OPEN.store(false, Ordering::SeqCst); + ui(|mut link| { + link.as_mut().set_modal_open(false); + }); +} + +fn reset_open(mut link: Pin<&mut QbzLink>) { + link.as_mut().set_url(QString::default()); + link.as_mut().set_platform(QString::default()); + link.as_mut().set_error(QString::default()); + link.as_mut().set_playlist_detected(false); + link.as_mut().set_playlist_provider(QString::default()); + link.as_mut().set_resolving(false); + link.as_mut().set_modal_open(true); +} + +impl qbz_link::QbzLink { + pub fn boot(self: Pin<&mut Self>) { + if QT_THREAD.set(self.qt_thread()).is_err() { + log::warn!("[qbz-qt] link resolver Qt thread already registered"); + } + } + + pub fn show(self: Pin<&mut Self>) { + OPEN.store(true, Ordering::SeqCst); + reset_open(self); + } + + pub fn close(mut self: Pin<&mut Self>) { + OPEN.store(false, Ordering::SeqCst); + self.as_mut().set_modal_open(false); + } + + pub fn url_edited(mut self: Pin<&mut Self>, url: QString) { + let platform = crate::link_resolver_qt::detect_platform(&url.to_string()); + self.as_mut().set_url(url); + self.as_mut().set_platform(QString::from(platform)); + self.as_mut().set_error(QString::default()); + self.as_mut().set_playlist_detected(false); + self.as_mut().set_playlist_provider(QString::default()); + } + + pub fn submit(mut self: Pin<&mut Self>, url: QString) { + let url = url.to_string().trim().to_string(); + if url.is_empty() || *self.as_ref().resolving() { + return; + } + self.as_mut().set_url(QString::from(url.as_str())); + self.as_mut().set_resolving(true); + self.as_mut().set_error(QString::default()); + self.as_mut().set_playlist_detected(false); + self.as_mut().set_playlist_provider(QString::default()); + + let runtime = crate::app(); + crate::spawn(async move { + let result = crate::link_resolver_qt::resolve(runtime, url).await; + ui(move |mut link| { + link.as_mut().set_resolving(false); + match result { + Ok(crate::link_resolver_qt::Outcome::Resolved(target)) => { + OPEN.store(false, Ordering::SeqCst); + link.as_mut().set_modal_open(false); + crate::link_resolver_qt::navigate(target); + } + Ok(crate::link_resolver_qt::Outcome::PlaylistDetected(provider)) => { + link.as_mut().set_playlist_detected(true); + link.as_mut() + .set_playlist_provider(QString::from(provider.as_str())); + } + Ok(crate::link_resolver_qt::Outcome::NotOnQobuz) => { + link.as_mut().set_error(QString::from( + qbz_i18n::t("This content is not available on Qobuz").as_str(), + )); + } + Err(e) => { + log::warn!("[qbz-qt] open-link resolve failed: {e}"); + link.as_mut().set_error(QString::from( + qbz_i18n::t("Could not resolve that link").as_str(), + )); + } + } + }); + }); + } + + pub fn open_importer(mut self: Pin<&mut Self>) { + OPEN.store(false, Ordering::SeqCst); + self.as_mut().set_modal_open(false); + crate::playlist_import_qt::open(); + } +} diff --git a/crates/qbz-qt/src/link_resolver_qt.rs b/crates/qbz-qt/src/link_resolver_qt.rs new file mode 100644 index 000000000..32dc15a89 --- /dev/null +++ b/crates/qbz-qt/src/link_resolver_qt.rs @@ -0,0 +1,164 @@ +//! Frontend-neutral music-link resolution adapted to the Qt navigation stack. +//! +//! `qbz-music-link` owns URL detection, cross-provider metadata lookup and the +//! smart Qobuz search. This module supplies the live `QbzCore` search bridge +//! and turns a resolved track into its album before handing the existing Qt +//! routers a navigation target. + +use std::sync::Arc; + +use async_trait::async_trait; +use qbz_app::shell::AppRuntime; +use qbz_core::LoggingAdapter; +use qbz_models::{Album, SearchResultsPage, Track}; +use qbz_music_link::{ + resolve_music_link, MusicLinkResult, QobuzSearchBridge, ResolvedLink, SongLinkClient, +}; + +type Runtime = Arc>; + +/// Result shape the two Qt consumers need: the modal and launcher deep links. +pub(crate) enum Outcome { + Resolved(ResolvedLink), + PlaylistDetected(String), + NotOnQobuz, +} + +struct CoreSearchBridge { + runtime: Runtime, +} + +#[async_trait] +impl QobuzSearchBridge for CoreSearchBridge { + async fn search_tracks( + &self, + query: &str, + limit: usize, + offset: usize, + ) -> Result, String> { + self.runtime + .core() + .search_tracks(query, limit as u32, offset as u32, None) + .await + .map_err(|e| e.to_string()) + } + + async fn search_albums( + &self, + query: &str, + limit: usize, + offset: usize, + ) -> Result, String> { + self.runtime + .core() + .search_albums(query, limit as u32, offset as u32, None) + .await + .map_err(|e| e.to_string()) + } +} + +/// Network-free platform hint for the modal's leading glyph. +pub(crate) fn detect_platform(url: &str) -> &'static str { + let lower = url.trim().to_ascii_lowercase(); + if lower.contains("qobuz.com/") || lower.starts_with("qobuzapp://") { + "qobuz" + } else if lower.contains("spotify.com/") || lower.starts_with("spotify:") { + "spotify" + } else if lower.contains("music.apple.com/") { + "apple" + } else if lower.contains("tidal.com/") { + "tidal" + } else if lower.contains("deezer.com/") { + "deezer" + } else if lower.contains("song.link/") + || lower.contains("album.link/") + || lower.contains("odesli.co/") + { + "songlink" + } else { + "" + } +} + +/// Resolve a URL and normalize `OpenTrack` to the album route the UI exposes. +pub(crate) async fn resolve(runtime: Runtime, url: String) -> Result { + let search = CoreSearchBridge { + runtime: runtime.clone(), + }; + let songlink = SongLinkClient::new(); + match resolve_music_link(&url, &songlink, &search) + .await + .map_err(|e| e.to_string())? + { + MusicLinkResult::Resolved { + link: ResolvedLink::OpenTrack(track_id), + .. + } => { + let track = runtime + .core() + .get_track(track_id) + .await + .map_err(|e| format!("track {track_id}: {e}"))?; + let album_id = track + .album + .as_ref() + .map(|album| album.id.clone()) + .filter(|id| !id.is_empty()) + .ok_or_else(|| format!("track {track_id} has no album"))?; + Ok(Outcome::Resolved(ResolvedLink::OpenAlbum(album_id))) + } + MusicLinkResult::Resolved { link, .. } => Ok(Outcome::Resolved(link)), + MusicLinkResult::PlaylistDetected { provider } => Ok(Outcome::PlaylistDetected(provider)), + MusicLinkResult::NotOnQobuz { .. } => Ok(Outcome::NotOnQobuz), + } +} + +/// Send a resolved entity through the same routers used by cards and rows. +pub(crate) fn navigate(link: ResolvedLink) { + match link { + ResolvedLink::OpenAlbum(id) => crate::open_album(id), + ResolvedLink::OpenArtist(id) => crate::open_artist(id.to_string()), + ResolvedLink::OpenPlaylist(id) => crate::open_playlist(id.to_string()), + // `resolve` normalizes this arm. Keep it exhaustive so a future caller + // cannot silently discard a direct track result. + ResolvedLink::OpenTrack(id) => { + log::error!("[qbz-qt] link resolver: unnormalized track {id}"); + } + } +} + +/// Resolve a launcher/D-Bus URL without opening the modal. +pub(crate) fn resolve_deep_link(url: String) { + let runtime = crate::app(); + crate::spawn(async move { + match resolve(runtime, url).await { + Ok(Outcome::Resolved(link)) => navigate(link), + Ok(Outcome::PlaylistDetected(provider)) => { + log::info!("[qbz-qt] deep link: {provider} playlist has no native route"); + } + Ok(Outcome::NotOnQobuz) => { + crate::toast_qt::error(qbz_i18n::t("This content is not available on Qobuz")); + } + Err(e) => { + log::warn!("[qbz-qt] deep link resolve failed: {e}"); + crate::toast_qt::error(qbz_i18n::t("Could not resolve that link")); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn platform_detection_is_specific_and_case_insensitive() { + assert_eq!(detect_platform("HTTPS://OPEN.QOBUZ.COM/album/a"), "qobuz"); + assert_eq!(detect_platform("spotify:track:123"), "spotify"); + assert_eq!(detect_platform("https://music.apple.com/a"), "apple"); + assert_eq!(detect_platform("https://listen.tidal.com/track/1"), "tidal"); + assert_eq!(detect_platform("https://www.deezer.com/track/1"), "deezer"); + assert_eq!(detect_platform("https://song.link/x"), "songlink"); + assert_eq!(detect_platform("https://example.com"), ""); + } +} diff --git a/crates/qbz-qt/src/local_album_actions.rs b/crates/qbz-qt/src/local_album_actions.rs index 7932d997a..6535b371f 100644 --- a/crates/qbz-qt/src/local_album_actions.rs +++ b/crates/qbz-qt/src/local_album_actions.rs @@ -452,17 +452,18 @@ pub(crate) fn genre_detail_doc( } } -/// Change one Genres album's selected physical copy. This cache is keyed by -/// album id because several expanded rows coexist; the routed AlbumView's -/// singleton `album_version_index` would make the last opened album win. -pub(crate) fn genre_select_version(album_id: &str, index: i32) -> Option { +/// Resolve one Genres album's selected physical copy without mutating the +/// cache. The caller publishes both values only after its generation check; +/// changing the cache here would let a superseded async request win. +pub(crate) fn genre_version_selection( + album_id: &str, + index: i32, +) -> Option<(GenreAlbumDetailDoc, Vec)> { let index = usize::try_from(index).ok()?; let versions = state(|s| s.genre_detail_versions.get(album_id).cloned())?; let selected = versions.get(index)?.1.clone(); - state(|s| { - s.genre_detail_raw.insert(album_id.to_string(), selected); - }); - Some(genre_detail_doc(album_id, versions.as_slice(), index)) + let doc = genre_detail_doc(album_id, versions.as_slice(), index); + Some((doc, selected)) } /// The header for ONE version — recomputed per version (the Slint recomputes @@ -1159,7 +1160,7 @@ mod version_tests { } #[test] - fn genres_version_selection_is_scoped_to_one_expanded_album() { + fn genres_version_selection_returns_one_expanded_albums_copy() { let album = "test:genres-version-picker"; let mut cd = copy("genres-cd", "plex", "Album", 2, 16); let mut hires = copy("genres-hires", "jellyfin", "Album (Remastered)", 3, 24); @@ -1175,40 +1176,32 @@ mod version_tests { ]; state(|s| { s.genre_detail_raw.insert(album.to_string(), hires); - s.genre_detail_versions.insert( - album.to_string(), - std::sync::Arc::new(versions.clone()), - ); + s.genre_detail_versions + .insert(album.to_string(), std::sync::Arc::new(versions.clone())); }); - let doc = genre_select_version(album, 1).expect("second version"); + let (doc, selected) = genre_version_selection(album, 1).expect("second version"); assert_eq!(doc.version_index, 1); assert_eq!(doc.versions.len(), 2); assert_eq!(doc.tracks.len(), 2); assert_eq!( - state(|s| s.genre_detail_raw[album] - .iter() - .map(|track| track.id) - .collect::>()), + selected.iter().map(|track| track.id).collect::>(), cd.iter().map(|track| track.id).collect::>() ); - let back = genre_select_version(album, 0).expect("first version again"); + let (back, selected) = genre_version_selection(album, 0).expect("first version again"); assert_eq!(back.version_index, 0); assert_eq!(back.versions.len(), 2); assert_eq!(back.tracks.len(), 3); assert_eq!( - state(|s| s.genre_detail_raw[album] - .iter() - .map(|track| track.id) - .collect::>()), + selected.iter().map(|track| track.id).collect::>(), versions[0] .1 .iter() .map(|track| track.id) .collect::>() ); - assert!(genre_select_version(album, 2).is_none()); + assert!(genre_version_selection(album, 2).is_none()); state(|s| { s.genre_detail_raw.remove(album); diff --git a/crates/qbz-qt/src/local_albums.rs b/crates/qbz-qt/src/local_albums.rs index 9283bf19c..956a365b4 100644 --- a/crates/qbz-qt/src/local_albums.rs +++ b/crates/qbz-qt/src/local_albums.rs @@ -51,6 +51,7 @@ pub fn load_albums_blocking() -> Result, String> { // server's rows from the grid (the mirror holds them all). let remote_path = crate::media_servers_qt::remote_cache_path(); let remote_words = crate::media_servers_qt::configured_words(); + let exclude_network = crate::offline_fwd::exclude_network_folders_now(); let page = with_db(|db| { db.get_albums_metadata_page( 0, @@ -59,7 +60,7 @@ pub fn load_albums_blocking() -> Result, String> { "artist", "asc", /* include_qobuz_downloads */ true, - /* exclude_network_folders */ false, + exclude_network, plex_path.as_deref(), remote_path.as_deref(), &remote_words, @@ -471,10 +472,11 @@ fn strip_edition_suffix(value: &str) -> &str { /// Folders tab, FLAT mode: the album grid grouped by DIRECTORY. Local-only /// by definition — Plex rows have no filesystem folder. pub fn load_folders_blocking() -> Result, String> { + let exclude_network = crate::offline_fwd::exclude_network_folders_now(); let albums = with_db(|db| { db.get_albums_with_full_filter( /* include_hidden */ false, /* include_qobuz_downloads */ true, - /* exclude_network_folders */ false, + exclude_network, ) }) .ok_or_else(|| "local library not available".to_string())?; @@ -515,13 +517,15 @@ pub fn load_artists_blocking() -> Result, String> { let remote_path = crate::media_servers_qt::remote_cache_path(); let remote_words = crate::media_servers_qt::configured_words(); let remote_on = !remote_words.is_empty(); + let exclude_network = crate::offline_fwd::exclude_network_folders_now(); // ONE db open for the three reads the merge needs. The album set is the // SAME query the Albums tab runs (Plex-aware union when the toggle is on), // so an artist's album count matches the grid the user sees. let (artists, albums, custom) = with_db(|db| { let artists = db.get_artists_with_filter( - /* include_qobuz_downloads */ true, /* exclude_network_folders */ false, + /* include_qobuz_downloads */ true, + exclude_network, )?; let albums = if plex_on || remote_on { db.get_albums_metadata_page( @@ -531,7 +535,7 @@ pub fn load_artists_blocking() -> Result, String> { "artist", "asc", /* include_qobuz_downloads */ true, - /* exclude_network_folders */ false, + exclude_network, plex_path.as_deref(), remote_path.as_deref(), &remote_words, @@ -541,7 +545,7 @@ pub fn load_artists_blocking() -> Result, String> { } else { db.get_albums_with_full_filter( /* include_hidden */ false, /* include_qobuz_downloads */ true, - /* exclude_network_folders */ false, + exclude_network, )? }; // Custom AND previously-cached (Qobuz-fetched) portraits. A missing @@ -814,6 +818,7 @@ pub fn load_tracks_page_blocking( let candidate_limit = TRACKS_PAGE + 1; let effective_sort = effective_tracks_sort(&request.sort, &request.group); let query_started = std::time::Instant::now(); + let exclude_network = crate::offline_fwd::exclude_network_folders_now(); // No library.db is valid for a remote-only installation. let local = if request.filter.source_enabled("local") || request.filter.source_enabled("offline") { @@ -823,7 +828,7 @@ pub fn load_tracks_page_blocking( request.offsets.local, candidate_limit, true, - false, + exclude_network, effective_sort, &request.filter.formats, request.filter.other_formats, diff --git a/crates/qbz-qt/src/local_bridge.rs b/crates/qbz-qt/src/local_bridge.rs index 4e850c5fc..892b21702 100644 --- a/crates/qbz-qt/src/local_bridge.rs +++ b/crates/qbz-qt/src/local_bridge.rs @@ -1808,23 +1808,15 @@ impl qbz_local::QbzLocal { crate::spawn(async move { let request_id = album_id.clone(); let selection = tokio::task::spawn_blocking(move || { - let index = usize::try_from(index).ok()?; - let (versions, filter_json) = crate::local_state::state(|state| { - Some(( - state.genre_detail_versions.get(&request_id)?.clone(), - state - .genre_detail_filters - .get(&request_id) - .cloned() - .unwrap_or_default(), - )) - })?; - let selected = versions.get(index)?.1.clone(); - let doc = crate::local_album_actions::genre_detail_doc( - &request_id, - versions.as_slice(), - index, - ); + let filter_json = crate::local_state::state(|state| { + state + .genre_detail_filters + .get(&request_id) + .cloned() + .unwrap_or_default() + }); + let (doc, selected) = + crate::local_album_actions::genre_version_selection(&request_id, index)?; Some(( request_id, selected, diff --git a/crates/qbz-qt/src/local_library_qt.rs b/crates/qbz-qt/src/local_library_qt.rs index cca4aefc7..e9db262e1 100644 --- a/crates/qbz-qt/src/local_library_qt.rs +++ b/crates/qbz-qt/src/local_library_qt.rs @@ -37,40 +37,28 @@ //! - Playback: Plex rows carry their `rating_key` in `source_item_id_hint` //! and resolve their direct-play part at play time. //! -//! Remaining POC-NOTEs (deliberate cuts, named for the effort report): -//! - Ephemeral folders, the tag editor, bulk multi-select bars, mixtapes and -//! the A-Z alpha strips are out of scope. -//! - Network-folder exclusion is always `false` here (the Slint keys it on -//! live connectivity); the index stays the source of truth. -//! - Plex playback downloads the whole part instead of Range-streaming it -//! (the Slint's own documented fallback — the streaming feeder lives inside -//! the Slint binary). +//! Network-folder visibility follows live connectivity through +//! `offline_fwd::exclude_network_folders_now`; source playback is delegated to +//! the shared registry/audible seams. -// The stable facade over the local_* modules: `local_bridge_ops` imports this -// module as `lib` and reaches everything through it, so the re-exports are -// load-bearing even where a plain grep for `local_library_qt::` misses them. -// (An earlier trim based on such a grep broke 47 call sites.) -#![allow(unused_imports)] +// The stable facade over the local_* modules. Keep this list equal to the +// symbols consumed through the `lib::` aliases in the bridge/ops/bulk modules +// plus the direct playback and diagnostics callers. // --- rows ------------------------------------------------------------------ -pub use crate::local_rows::{ - album_key, artist_key, badge_source, folder_key, tier_of, to_json, track_key, AlbumRow, - ArtistRow, FolderDetail, LocalCounts, SubfolderRow, TrackRow, TreeNode, -}; +pub use crate::local_rows::to_json; // --- state / prefs --------------------------------------------------------- pub use crate::local_state::{ - album_mode, begin_tracks_load, commit_tracks_page, counts, has_library, set_album_mode, - set_tracks_filter, set_tracks_group, set_tracks_query, set_tracks_sort, state, tracks_filter, - tracks_generation, tracks_group, tracks_has_more, tracks_sort, with_db, TrackSourceOffsets, - TracksLoadRequest, + album_mode, begin_tracks_load, counts, has_library, set_album_mode, set_tracks_filter, + set_tracks_group, set_tracks_query, set_tracks_sort, state, tracks_filter, tracks_generation, + tracks_group, tracks_has_more, tracks_sort, }; // --- queries --------------------------------------------------------------- pub use crate::local_albums::{ - fetch_album_tracks_blocking, load_album_detail_filtered_blocking, - load_albums_blocking, load_artists_blocking, load_counts_blocking, load_folders_blocking, - load_tracks_page_blocking, + load_album_detail_filtered_blocking, load_albums_blocking, load_artists_blocking, + load_counts_blocking, load_folders_blocking, load_tracks_page_blocking, }; // --- folder tree ----------------------------------------------------------- @@ -80,18 +68,12 @@ pub use crate::local_tree::{ }; // --- artwork --------------------------------------------------------------- -pub use crate::local_artwork::{fetch_plex_misses, resolve_window_blocking, ArtworkWindow}; +pub use crate::local_artwork::{fetch_plex_misses, resolve_window_blocking}; // --- playback -------------------------------------------------------------- pub use crate::local_playback::{ - enqueue, enqueue_album_filtered, local_queue_track, play_album, play_album_filtered, - play_current_if_local, play_folder, play_folder_track, play_tracks_visible, LocalPlay, -}; - -// --- plex ------------------------------------------------------------------ -pub use crate::local_plex::{ - is_configured as plex_configured, is_enabled as plex_enabled, is_syncing as plex_syncing, - sync_now as plex_sync_now, + enqueue, enqueue_album_filtered, play_album, play_album_filtered, play_current_if_local, + play_folder, play_folder_track, play_tracks_visible, }; /// Drop every cached document AND unbind the Plex store (logout / user diff --git a/crates/qbz-qt/src/lyrics_qt.rs b/crates/qbz-qt/src/lyrics_qt.rs index cfed8a602..2752aef8d 100644 --- a/crates/qbz-qt/src/lyrics_qt.rs +++ b/crates/qbz-qt/src/lyrics_qt.rs @@ -42,11 +42,11 @@ //! sink's badge refresh and the facade's disconnect tail, which the cast //! suspend also rides). //! -//! # Not ported (no seam in this shell — see the effort report) -//! - Immersive / miniplayer lyrics surfaces: neither host exists here. -//! - The "Lyrics copied" / "translation unavailable" TOASTS: the Qt port has -//! no toast host, so both surface as an inline notice line in the panel -//! ([`notice`]) instead of silently doing nothing. +//! # Host surfaces +//! The desktop panel, Immersive focus/split modes and miniplayer all consume +//! this same document and sync math. "Lyrics copied" / "translation +//! unavailable" remain panel-local inline [`notice`] messages rather than +//! app-wide toasts. use std::pin::Pin; use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, AtomicU64, Ordering}; @@ -97,8 +97,8 @@ pub mod qbz_lyrics_bridge { #[qproperty(bool, show_translation)] // A translation refetch is in flight (the toggle shows a busy tint). #[qproperty(bool, translation_busy)] - // Inline notice line (this shell has no toast host): translation - // failures and the copy confirmation land here. "" = hidden. + // Panel-local notice line: translation failures and the copy + // confirmation land here. "" = hidden. #[qproperty(QString, notice)] // --- Display prefs (Slint LyricsState S5 block) ------------------- // Persisted per user in /qbz/users//lyrics_prefs.json — @@ -982,7 +982,7 @@ async fn resolve_target_language(runtime: &Runtime, pref: &str) -> Option Vec { "artist", "asc", /* include_qobuz_downloads */ true, - // GAP: the reference passes `local_library::exclude_network_folders_now()` - // (qbz/src/local_library.rs). This port has no equivalent and every - // other call site hardcodes `false` (local_albums.rs:42,70,94,173); - // the builder must see the SAME set the Albums tab shows. /* exclude_network_folders */ - false, + crate::offline_fwd::exclude_network_folders_now(), plex_path.as_deref(), remote_path.as_deref(), &remote_words, diff --git a/crates/qbz-qt/src/myqbz_builder_qt.rs b/crates/qbz-qt/src/myqbz_builder_qt.rs index c29d8552f..78597ddf1 100644 --- a/crates/qbz-qt/src/myqbz_builder_qt.rs +++ b/crates/qbz-qt/src/myqbz_builder_qt.rs @@ -26,12 +26,8 @@ //! fetch produced no `plex` row (`qbz/src/main.rs:2889-2905`). //! //! PORT NOTES (divergences from the reference, all deliberate): -//! - `exclude_network_folders` is **`false`**. GAP: the reference reads -//! `qbz/src/local_library.rs::exclude_network_folders_now()`; this port has no -//! equivalent and all four existing `get_albums_metadata_page` call sites -//! hardcode `false` (`local_albums.rs:42,70,94,173`). The builder must see the -//! SAME candidate set the Albums tab shows, and here that set is the -//! unfiltered one (spec 02 §6.8 / open question #15). +//! - Network-folder exclusion uses the shared live offline/connectivity gate, +//! so the builder sees the same candidate set as Local Library. //! - The reference DECODES the avatar into a `slint::Image`; here the avatar is //! a `file://` path resolved through `artwork_qt` (`artistAvatarPath`). //! - Candidate covers are NOT downloaded: no `DiscoCandidateRow` cell renders diff --git a/crates/qbz-qt/src/myqbz_detail_qt.rs b/crates/qbz-qt/src/myqbz_detail_qt.rs index dffe4d45c..0e1cd28fd 100644 --- a/crates/qbz-qt/src/myqbz_detail_qt.rs +++ b/crates/qbz-qt/src/myqbz_detail_qt.rs @@ -26,9 +26,8 @@ //! returns the marked English literal and the reference never wraps it in //! `t()`), while an unresolved one reads the translated `t("ALBUM")`. //! -//! Out of scope, 1:1 with the port's own dependencies (spec 02 §1.1): the -//! offline availability filter and the offline-index badge resolution — this -//! port opens no offline-cache index. +//! Offline loads use the active cache index both to hide unavailable entries +//! and to hydrate cached Qobuz quality/type badges without calling the API. use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -1489,6 +1488,74 @@ fn resolve_from_tracks(item: &MixtapeCollectionItem, tracks: &[QueueTrack]) -> R } } +/// Resolve a cached Qobuz item's badge from the local offline index. This is +/// the offline equivalent of `resolve_from_tracks`; Qobuz playlist membership +/// cannot be derived locally and deliberately returns `None`. +async fn resolve_offline_cached(item: &MixtapeCollectionItem) -> Option { + use qbz_offline_cache::OfflineCacheStatus; + + if item.source != AlbumSource::Qobuz { + return None; + } + let offline = crate::offline_qt::get().await?; + let guard = offline.db.lock().await; + let db = guard.as_ref()?; + + let (info, ready_count) = match item.item_type { + ItemType::Album => { + let ready: Vec = db + .get_album_tracks(&item.source_item_id) + .ok()? + .into_iter() + .filter(|track| matches!(track.status, OfflineCacheStatus::Ready)) + .collect(); + let count = ready.len(); + (ready.into_iter().next()?, count) + } + ItemType::Track => { + let id = item.source_item_id.parse::().ok()?; + let info = db.get_track(id).ok().flatten()?; + if !matches!(info.status, OfflineCacheStatus::Ready) { + return None; + } + (info, 1) + } + ItemType::Playlist => return None, + }; + + let quality_tier = match info.bit_depth { + Some(depth) if depth >= 24 => "hires", + Some(_) => "cd", + None if info.quality.to_ascii_lowercase().contains("hires") => "hires", + None => "", + }; + let quality_detail = if quality_tier.is_empty() { + String::new() + } else { + crate::home_qt::quality_detail_from_parts(info.bit_depth, info.sample_rate) + }; + let type_label_text = match item.item_type { + ItemType::Album => { + let count = item + .track_count + .filter(|count| *count > 0) + .map(|count| count as u32) + .unwrap_or(ready_count as u32); + classify_release_type(Some(count)).to_uppercase() + } + other => type_label(other), + }; + + Some(ResolvedItem { + source_kind: "qobuz".to_string(), + quality_tier: quality_tier.to_string(), + quality_detail, + type_label: type_label_text, + artwork_url: String::new(), + artist_id: String::new(), + }) +} + /// Insert into `RESOLVE_CACHE` and patch the RENDERED row IN PLACE. A full /// array replacement would reset `ListView.contentY` and throw the user to the /// top (spec 02 §9.1) — and this is not a re-derive, so it must not clear the @@ -1605,6 +1672,7 @@ fn resolve_items(runtime: Arc>) { } crate::spawn(async move { let semaphore = Arc::new(Semaphore::new(MAX_RESOLVE_CONCURRENCY)); + let offline = crate::offline_fwd::engine().status().is_offline(); let mut handles = Vec::with_capacity(pending.len()); for item in pending { let sem = Arc::clone(&semaphore); @@ -1617,8 +1685,19 @@ fn resolve_items(runtime: Arc>) { if current_id() != collection_id { return None; } - let tracks = crate::myqbz_play_qt::fetch_item_tracks(&runtime, &item).await; - let resolved = resolve_from_tracks(&item, &tracks); + let resolved = if offline { + resolve_offline_cached(&item).await + } else { + None + }; + let resolved = match resolved { + Some(resolved) => resolved, + None => { + let tracks = + crate::myqbz_play_qt::fetch_item_tracks(&runtime, &item).await; + resolve_from_tracks(&item, &tracks) + } + }; let repair = item .artwork_url .as_deref() @@ -2070,9 +2149,6 @@ pub(crate) fn row_item_type(source_item_id: &str) -> String { pub(crate) async fn load(runtime: &Arc>, id: String) { reset(); - // D11.c GAP: the reference drops items failing the offline availability - // rule here (`myqbz.rs:231 retain_available_offline`). This port opens no - // offline-cache index, so no item is hidden (spec 02 §1.1). // ONE blocking hop for both reads: the collection (DB) and this // collection's persisted accordion set (a small per-user JSON). Neither may // run on the GUI thread, and pairing them keeps the load at one hop. @@ -2087,12 +2163,26 @@ pub(crate) async fn load(runtime: &Arc>, id: String) .ok() .flatten(); - let Some((collection, stored_open)) = loaded else { + let Some((mut collection, stored_open)) = loaded else { log::warn!("[qbz-qt] myqbz_detail load({id}): collection not found"); apply_not_found(); return; }; + if crate::offline_fwd::engine().status().is_offline() { + let before = collection.items.len(); + let items: Vec<&MixtapeCollectionItem> = collection.items.iter().collect(); + let availability = crate::myqbz_qt::offline_availability(&items).await; + drop(items); + collection + .items + .retain(|item| availability.item_available(item)); + let hidden = before.saturating_sub(collection.items.len()); + if hidden > 0 { + log::info!("[qbz-qt] myqbz_detail offline filter hid {hidden} unavailable item(s)"); + } + } + apply(collection, stored_open); let missing = attach_artwork(); publish_current(); @@ -2245,35 +2335,23 @@ pub(crate) fn bulk_action(id: String) { } "remove-selected" => crate::myqbz_edit_qt::remove_selected(), "clear" => clear_selection(), - // The Slint's seventh action resolves the selection to Qobuz track ids - // and opens the global playlist picker (LIVE here since - // QbzPlaylistPicker landed). - // - // Only QOBUZ TRACK items resolve to a catalog id. An album or playlist - // item is a container whose members this bar never fetched, and a - // local / Plex item's `source_item_id` is a library path or a rating - // key — the picker's Qobuz arm would read either as a catalog id and - // add an unrelated track (the id-confusion class - // `playlist_picker_qt.rs`'s header exists to make unrepresentable). - // Those rows are SKIPPED and counted in the log rather than guessed at. + // Resolve every selected container through qbz-source, then pass only + // the resulting Qobuz catalog tracks to the Qobuz playlist picker. + // Local/Plex tracks are skipped by the shared source registry. "add-to-playlist" => { let items = selected_full_items(); - let ids: Vec = items - .iter() - .filter(|it| it.item_type == ItemType::Track && it.source == AlbumSource::Qobuz) - .map(|it| it.source_item_id.clone()) - .collect(); - let skipped = items.len() - ids.len(); - if skipped > 0 { - log::info!( - "[qbz-qt] myqbz_detail add-to-playlist: skipped {skipped} non-Qobuz-track \ - item(s) (album / playlist containers and local / Plex refs have no catalog id)" - ); - } - if ids.is_empty() { + if items.is_empty() { return; } - crate::playlist_picker_qt::open_for_ids(&crate::app(), ids); + crate::spawn(async move { + let runtime = crate::app(); + let ids = crate::myqbz_play_qt::resolve_bulk_qobuz_track_ids(&runtime, &items).await; + if ids.is_empty() { + crate::toast_qt::error(qbz_i18n::t("No tracks to add")); + return; + } + crate::playlist_picker_qt::open_for_ids(&runtime, ids); + }); } other => { log::warn!("[qbz-qt] myqbz_detail bulk_action: unknown id {other}"); diff --git a/crates/qbz-qt/src/myqbz_play_qt.rs b/crates/qbz-qt/src/myqbz_play_qt.rs index a4030b751..4426704ba 100644 --- a/crates/qbz-qt/src/myqbz_play_qt.rs +++ b/crates/qbz-qt/src/myqbz_play_qt.rs @@ -244,14 +244,6 @@ pub(crate) async fn fetch_item_tracks( /// Resolve the selected items' QOBUZ track ids for the bulk "Add to playlist" /// flow: Qobuz playlists only accept Qobuz track ids, so local/Plex tracks are /// skipped. Ids in resolution order. -/// -/// UNCALLED ON PURPOSE — its only caller would be -/// `myqbz_detail_qt::bulk_action`'s `"add-to-playlist"` arm, which cannot be -/// implemented in this port: there is no global playlist picker -/// (`playlist_picker::open_multi`, `main.rs:6476-6500` in the reference), the -/// same gap `LabelView.qml:18` and `MixView.qml:32` already record. Kept -/// verbatim so wiring the picker is a one-line call, not a re-port. -#[allow(dead_code)] pub(crate) async fn resolve_bulk_qobuz_track_ids( _runtime: &Runtime, items: &[MixtapeCollectionItem], diff --git a/crates/qbz-qt/src/myqbz_qt.rs b/crates/qbz-qt/src/myqbz_qt.rs index b3f4b3015..fa2d09226 100644 --- a/crates/qbz-qt/src/myqbz_qt.rs +++ b/crates/qbz-qt/src/myqbz_qt.rs @@ -21,18 +21,18 @@ //! is ONE `download_missing` pass followed by ONE republish, and the toolbar //! setters are pure in-memory rebuilds off the per-grid cache — no refetch. //! -//! D11.c GAP: the Slint drops items that are unavailable offline and then drops -//! collections left with zero items (`myqbz.rs:231 retain_available_offline`). -//! This port opens no offline-cache index at all (`auth_qt.rs:7-18`), so -//! nothing is hidden — the grids load from SQLite normally, which is always -//! available. Add the call in `load_grid` when the offline index lands. +//! Offline loads filter against one availability snapshot: local items remain, +//! Plex is allowed only in induced offline mode, and Qobuz items require a +//! ready cache entry plus a valid subscription grace verdict. use std::collections::HashSet; use std::path::Path; use std::sync::{LazyLock, Mutex, MutexGuard}; use cxx_qt_lib::QString; -use qbz_models::mixtape::{AlbumSource, CollectionKind, MixtapeCollection}; +use qbz_models::mixtape::{ + AlbumSource, CollectionKind, ItemType, MixtapeCollection, MixtapeCollectionItem, +}; use serde::Serialize; /// The grid-card mosaic renders at 184px (spec 01 §5.2), which is what the @@ -75,6 +75,138 @@ pub(crate) fn kind_from_str(s: &str) -> CollectionKind { } } +// --------------------------------------------------------------------------- +// Offline availability +// --------------------------------------------------------------------------- + +/// One batch snapshot used by both the My QBZ grids and detail view. Building +/// it once prevents a database/index read for every card. +pub(crate) struct OfflineAvailability { + cached_track_ids: HashSet, + cached_album_ids: HashSet, + plex_local_rows: HashSet, + qobuz_allowed: bool, + plex_allowed: bool, +} + +impl OfflineAvailability { + pub(crate) fn item_available(&self, item: &MixtapeCollectionItem) -> bool { + match item.source { + AlbumSource::Qobuz => { + if !self.qobuz_allowed { + return false; + } + match item.item_type { + ItemType::Album => self.cached_album_ids.contains(&item.source_item_id), + ItemType::Track => item + .source_item_id + .parse::() + .map(|id| self.cached_track_ids.contains(&id)) + .unwrap_or(false), + ItemType::Playlist => false, + } + } + AlbumSource::Local => { + if item.source_item_id.starts_with("plex:") { + return self.plex_allowed; + } + match item.item_type { + ItemType::Track => match item.source_item_id.parse::() { + Ok(id) if self.plex_local_rows.contains(&id) => self.plex_allowed, + Ok(_) => true, + Err(_) => false, + }, + ItemType::Playlist => false, + ItemType::Album => true, + } + } + } + } +} + +/// Build the offline availability snapshot for a set of collection items. +pub(crate) async fn offline_availability( + items: &[&MixtapeCollectionItem], +) -> OfflineAvailability { + let (cached_track_ids, cached_album_ids) = match crate::offline_qt::get().await { + Some(offline) => { + let guard = offline.db.lock().await; + match guard.as_ref().map(|db| db.get_all_tracks()) { + Some(Ok(tracks)) => { + let mut ids = HashSet::new(); + let mut albums = HashSet::new(); + for track in tracks { + if matches!( + track.status, + qbz_offline_cache::OfflineCacheStatus::Ready + ) { + ids.insert(track.track_id); + if let Some(album_id) = track.album_id { + albums.insert(album_id); + } + } + } + (ids, albums) + } + _ => (HashSet::new(), HashSet::new()), + } + } + None => (HashSet::new(), HashSet::new()), + }; + + let local_track_ids: Vec = items + .iter() + .filter(|item| item.source == AlbumSource::Local && item.item_type == ItemType::Track) + .filter_map(|item| item.source_item_id.parse::().ok()) + .collect(); + let plex_local_rows = if local_track_ids.is_empty() { + HashSet::new() + } else { + tokio::task::spawn_blocking(move || { + crate::library_db_qt::with_db(false, |db| { + let mut plex = HashSet::new(); + for id in local_track_ids { + if let Some(track) = db.get_track(id)? { + if track.source.as_deref() == Some("plex") { + plex.insert(id); + } + } + } + Ok(plex) + }) + .unwrap_or_default() + }) + .await + .unwrap_or_default() + }; + + OfflineAvailability { + cached_track_ids, + cached_album_ids, + plex_local_rows, + qobuz_allowed: crate::offline_fwd::offline_playback_allowed(), + plex_allowed: crate::offline_fwd::engine().status().mode + == qbz_app::offline_mode::OfflineMode::InducedOffline, + } +} + +pub(crate) async fn retain_available_offline( + rows: Vec, +) -> Vec { + let items: Vec<&MixtapeCollectionItem> = + rows.iter().flat_map(|collection| collection.items.iter()).collect(); + let availability = offline_availability(&items).await; + drop(items); + rows.into_iter() + .filter_map(|mut collection| { + collection + .items + .retain(|item| availability.item_available(item)); + (!collection.items.is_empty()).then_some(collection) + }) + .collect() +} + // --------------------------------------------------------------------------- // Documents (spec 02 §5.1 GridDoc / §5.4 CreateDoc) // --------------------------------------------------------------------------- @@ -651,16 +783,16 @@ pub(crate) fn load_grid(grid: Grid) { let rows = tokio::task::spawn_blocking(move || list_collections(kind_arg)) .await .unwrap_or_default(); - let rows: Vec = match grid { + let mut rows: Vec = match grid { Grid::Mixtapes => rows, Grid::Collections => rows .into_iter() .filter(|c| c.kind != CollectionKind::Mixtape) .collect(), }; - // D11.c GAP: while offline the Slint would drop unavailable items here - // (`myqbz.rs:231 retain_available_offline`). No offline index in this - // port — nothing is hidden. See the module header. + if crate::offline_fwd::engine().status().is_offline() { + rows = retain_available_offline(rows).await; + } apply(grid, rows); artwork_pass(grid).await; }); diff --git a/crates/qbz-qt/src/now_playing.rs b/crates/qbz-qt/src/now_playing.rs index 4ff2f05b9..e86cbd571 100644 --- a/crates/qbz-qt/src/now_playing.rs +++ b/crates/qbz-qt/src/now_playing.rs @@ -397,7 +397,6 @@ pub fn set_track(meta: TrackMeta) { /// (Qobuz-sourced, not local/Plex); ungoverned tracks keep the cause line off. /// Publishes nothing on its own: the seed only changes what the NEXT /// `set_effective_stream` computes. -#[allow(dead_code)] // wired by the poll pump / a cast service (see GLUE) pub fn set_catalog_quality(bit_depth: Option, sample_rate: Option, governed: bool) { crate::quality_state::seed_track(bit_depth, sample_rate, governed); } @@ -406,7 +405,6 @@ pub fn set_catalog_quality(bit_depth: Option, sample_rate: Option, gov /// (`sample_rate` / `bit_depth`; 0 = not reported yet). Re-evaluates the /// downgrade block and republishes ONLY when the params actually moved, so a /// steady stream costs one atomic compare per tick and no Qt hop. -#[allow(dead_code)] // wired by the poll pump / a cast service (see GLUE) pub fn set_effective_stream(eff_rate_hz: u32, eff_bits: u32) { let (changed, snapshot) = with_model(|m| { if m.eff_rate_hz == eff_rate_hz && m.eff_bits == eff_bits { @@ -459,7 +457,6 @@ pub fn clear_track() { /// A Chromecast/DLNA session connected or dropped (`cast_service.rs` /// `push_connection_state`). `protocol` is "cast" | "dlna". -#[allow(dead_code)] // wired by the poll pump / a cast service (see GLUE) pub fn set_cast_session(active: bool, protocol: &str, target: &str) { mutate(|m| { m.cast_active = active; @@ -523,7 +520,6 @@ pub fn set_remote_volume_locked(locked: bool) { /// cannot see (the local player is stopped while casting — /// `cast_service.rs` `publish_delivered_quality`). `limit_cause` is a /// `qbz_models::QualityLimit` discriminant already classified by the caller. -#[allow(dead_code)] // wired by the poll pump / a cast service (see GLUE) pub fn set_cast_delivered(delivered: Delivered) { mutate(|m| m.delivered = delivered); } diff --git a/crates/qbz-qt/src/offline_fwd.rs b/crates/qbz-qt/src/offline_fwd.rs index e0c321f38..91925e69c 100644 --- a/crates/qbz-qt/src/offline_fwd.rs +++ b/crates/qbz-qt/src/offline_fwd.rs @@ -7,12 +7,9 @@ //! only the process globals, the per-user binding, and the status -> QML //! forwarder (`engine().subscribe()` watch -> `CxxQtThread::queue`). //! -//! POC-NOTE (skipped vs the Slint glue): `check_now` / `seed_settings` / -//! `request_recheck` (Settings > Offline UI — no settings views in the -//! POC), `offline_playback_allowed` (no playback yet), and the -//! subscription purge consumer (`spawn_subscription_purge_check` — needs -//! the offline cache, which this phase does not bring up; TODO when the -//! offline cache is wired). +//! Remaining parity debt: the subscription-expiry purge consumer is not +//! spawned. Connectivity recheck, Settings wiring, the download cache and the +//! offline-playback eligibility gate are live. use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock, Mutex, OnceLock}; @@ -40,6 +37,13 @@ pub fn engine() -> Arc { Arc::clone(&ENGINE) } +/// Exclude registered network folders only when connectivity is confirmed +/// down. Manual offline mode with a live LAN intentionally keeps NAS content +/// visible; `Unknown` also fails open to avoid hiding it during boot probes. +pub fn exclude_network_folders_now() -> bool { + engine().status().connectivity == Connectivity::Down +} + /// Spawn the connectivity actor and attach it to the engine. Called once /// during boot (from a tokio context — the spawns need the runtime); the /// monitoring runs for the whole app lifetime, login screen included (the @@ -84,9 +88,19 @@ pub fn start_ui_forwarder() { crate::spawn(async move { let mut rx = engine().subscribe(); + let mut previous_connectivity = None; loop { let status = *rx.borrow_and_update(); + let network_visibility_changed = previous_connectivity + .map(|previous| { + (previous == Connectivity::Down) != (status.connectivity == Connectivity::Down) + }) + .unwrap_or(false); + previous_connectivity = Some(status.connectivity); crate::session_bridge::ui(move |b| apply_status(b, status)); + if network_visibility_changed && crate::local_state::has_library() { + crate::local_bridge_ops::reload_browse(); + } if rx.changed().await.is_err() { break; } @@ -135,8 +149,8 @@ pub fn user_data_dir(user_id: u64) -> Option { /// Called on every session activation (login, restore, offline entry). /// Best-effort: failures are logged, never block entry. /// -/// POC-NOTE: the Slint version also spawns the subscription purge check -/// here — omitted (needs the offline cache; see module docs). +/// The Slint version also spawns the subscription purge check here; that +/// consumer remains the module's one documented parity gap. pub fn init_for_user(base_dir: &Path) { if let Err(e) = engine().init_for_user(base_dir) { log::error!("[qbz-qt] offline mode engine init failed: {e}"); diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs index 56a1e0301..312d08d01 100644 --- a/crates/qbz-qt/src/playback_qt.rs +++ b/crates/qbz-qt/src/playback_qt.rs @@ -5,22 +5,7 @@ //! and the poll loop (PlaybackEvent -> UI state, track-change meta refresh, //! end-of-track advance). //! -//! OWED PORTS vs playback.rs. These are DEBT, not scope decisions: a fix that -//! lived in the frontend is not inherited and has to be replicated (TRACK-RULES -//! rule 8, whose title is "los fixes de backend deben llegar al Qt"). Each one -//! is tracked in `qbz-nix-docs/qt-frontend/2026-07-30-parity-debt/PARITY-DEBT.md` -//! — add there, not here, so a note cannot outlive its gap unnoticed. That is -//! exactly how gapless stayed broken: this header advertised it for weeks. -//! -//! - Session persist / resume-position: both prefs are persisted by settings -//! and read by nobody. -//! - Streaming quality: seeded from ui_prefs ("streaming_quality") and -//! live-updated by Settings > Audio (settings_qt). The #638 device-cap clamp -//! lives in the Slint glue and is NOT ported. -//! - Offline-cache tier (offline bytes), QConnect branches. -//! -//! DONE, previously listed here — kept as one line each so the list above -//! only ever names REAL gaps: +//! Frontend-owned playback responsibilities implemented here include: //! - Stop-after-this-song (#35) — the end-of-track arm consumes the marker and //! pauses AHEAD of repeat/shuffle, and the gapless pre-queue guard that was //! already here now defends a marker the UI can actually place. @@ -59,7 +44,7 @@ use qbz_models::{PageArtistTrack, Quality, QueueTrack, RepeatMode}; /// The request tier for every play. Persisted in ui_prefs.json ("streaming_quality") /// and applied here from Settings > Audio (settings_qt). Default "hires_plus". -static POC_QUALITY: RwLock = RwLock::new(Quality::UltraHiRes); +static STREAMING_QUALITY: RwLock = RwLock::new(Quality::UltraHiRes); /// Map a ui_prefs quality key to the request tier (settings.rs STREAMING_QUALITIES). fn quality_for_key(key: &str) -> Quality { @@ -74,7 +59,7 @@ fn quality_for_key(key: &str) -> Quality { /// Settings > Audio > Streaming quality writes through here (also used at /// startup to seed from the persisted prefs). pub fn set_streaming_quality(key: &str) { - *POC_QUALITY.write().unwrap() = quality_for_key(key); + *STREAMING_QUALITY.write().unwrap() = quality_for_key(key); } /// The raw streaming-quality PREFERENCE, uncapped. @@ -85,14 +70,14 @@ pub fn set_streaming_quality(key: &str) { /// LOCAL play goes through `local_playback_quality` instead — reach for this /// one only if you can say why a cap must not apply. pub(crate) fn current_quality() -> Quality { - *POC_QUALITY.read().unwrap() + *STREAMING_QUALITY.read().unwrap() } /// The request tier for LOCAL playback + the cause that shaped it — the one /// place the preference and the detected device cap (#638 fix 3) are /// reconciled. Port of `crates/qbz/src/playback.rs local_playback_quality`. /// -/// Costs two uncontended `RwLock` reads (`POC_QUALITY` + the device-cap +/// Costs two uncontended `RwLock` reads (`STREAMING_QUALITY` + the device-cap /// cache), so it is safe to resolve per play; nothing here reads a file or /// probes hardware. That is what lets the play funnel own the decision /// instead of taking it as a parameter. diff --git a/crates/qbz-qt/src/player_bridge.rs b/crates/qbz-qt/src/player_bridge.rs index 06c821f24..afceaade5 100644 --- a/crates/qbz-qt/src/player_bridge.rs +++ b/crates/qbz-qt/src/player_bridge.rs @@ -27,9 +27,8 @@ pub mod qbz_player { #[qml_element] #[qml_singleton] // --- Now playing (Slint NowPlayingState; np_ prefix) ------------ - // POC: fed by a static NowPlayingModel (src/now_playing.rs) with - // empty-state defaults; phase 4 swaps the data source for the real - // player poll. + // Fed by the live NowPlayingModel (`now_playing.rs`), which the player + // poll updates and which supplies empty-state defaults before a track. #[qproperty(bool, np_has_track)] #[qproperty(QString, np_title)] #[qproperty(QString, np_artist)] @@ -163,8 +162,7 @@ pub mod qbz_player { #[qinvokable] fn request_np_artwork_size(self: Pin<&mut QbzPlayer>, px: i32); - // --- Transport (phase 4 wires the player; POC log-and-noop, except - // the pure-UI toggles which mutate the NowPlayingModel) ----------- + // --- Live transport ---------------------------------------------- #[qinvokable] fn toggle_play(self: Pin<&mut QbzPlayer>); #[qinvokable] diff --git a/crates/qbz-qt/src/playlist_manager_qt.rs b/crates/qbz-qt/src/playlist_manager_qt.rs index fe2fd874a..987332740 100644 --- a/crates/qbz-qt/src/playlist_manager_qt.rs +++ b/crates/qbz-qt/src/playlist_manager_qt.rs @@ -605,7 +605,6 @@ async fn load(runtime: &Runtime) -> PmData { }), id: p.id, name: p.name, - description: p.description, offline_only: p.offline_only, track_count: p.track_count, is_favorite: p.favorite, diff --git a/crates/qbz-qt/src/playlist_manager_rows.rs b/crates/qbz-qt/src/playlist_manager_rows.rs index 3b1b23fe5..b606968ea 100644 --- a/crates/qbz-qt/src/playlist_manager_rows.rs +++ b/crates/qbz-qt/src/playlist_manager_rows.rs @@ -87,10 +87,6 @@ impl PmPlaylist { pub(crate) struct PmLocalPlaylist { pub id: String, pub name: String, - /// From `repo::LocalPlaylist.description` (§5.2). Same story as the Qobuz - /// row's: written here, read by block 3's editor. - #[allow(dead_code)] - pub description: Option, pub offline_only: bool, pub track_count: u32, pub is_favorite: bool, diff --git a/crates/qbz-qt/src/qconnect_bridge.rs b/crates/qbz-qt/src/qconnect_bridge.rs index 38c466c55..2ccfd2b5d 100644 --- a/crates/qbz-qt/src/qconnect_bridge.rs +++ b/crates/qbz-qt/src/qconnect_bridge.rs @@ -169,9 +169,9 @@ impl qbz_qconnect::QbzQConnect { // connect() (qconnect_qt.rs, the D5 msgid) — don't // stack a second toast with the same text. Every other // failure is silent in the reference: it only lands in - // the service's `last_error`, whose sole reader (the - // diagnostics snapshot) is §9-D10-skipped — so surface - // it here instead. + // the service's `last_error`. Qt also retains that in + // diagnostics, but the immediate toast keeps a failed + // click from appearing inert. if err != "Qobuz Connect is unavailable while offline" { crate::toast_qt::error(err); } diff --git a/crates/qbz-qt/src/qconnect_engine_qt.rs b/crates/qbz-qt/src/qconnect_engine_qt.rs index 7e57fc7d7..866eff816 100644 --- a/crates/qbz-qt/src/qconnect_engine_qt.rs +++ b/crates/qbz-qt/src/qconnect_engine_qt.rs @@ -25,10 +25,8 @@ //! NOT a shared module: the Slint crate keeps its own copy too, and extracting //! commonalities into a shared crate is out of scope (backend untouchable). //! -//! Wired by the QConnect facade (block B3) + event sink (block B4); until -//! those land the constructor is unused, so the module keeps the reference's -//! `#![allow(dead_code)]` (same convention as `toast_qt.rs`). -#![allow(dead_code)] +//! Wired by the QConnect facade and event sink. The module intentionally has +//! no blanket dead-code suppression: an orphaned renderer seam must warn. use std::sync::Arc; use std::time::Duration; diff --git a/crates/qbz-qt/src/qconnect_event_sink_qt.rs b/crates/qbz-qt/src/qconnect_event_sink_qt.rs index f8dfb72dc..4a9cc8e6c 100644 --- a/crates/qbz-qt/src/qconnect_event_sink_qt.rs +++ b/crates/qbz-qt/src/qconnect_event_sink_qt.rs @@ -19,8 +19,8 @@ //! //! NOT ported forward, replicating the reference's own absences (§9 D5/D6): //! controller-mode auto-skip on PlaybackError (the reference TODO) and the -//! lifecycle badge wiring. -#![allow(dead_code)] +//! lifecycle badge wiring. The module intentionally has no blanket dead-code +//! suppression: an orphaned event arm must warn. use std::sync::{Arc, OnceLock, Weak}; diff --git a/crates/qbz-qt/src/qconnect_qt.rs b/crates/qbz-qt/src/qconnect_qt.rs index d6fec461a..44ccb26c3 100644 --- a/crates/qbz-qt/src/qconnect_qt.rs +++ b/crates/qbz-qt/src/qconnect_qt.rs @@ -30,17 +30,14 @@ //! `crate::offline_mode::engine()`); //! (c) call-site renames to the Qt runtime accessors (`crate::app()`), //! `qconnect_transport_qt`, `qconnect_engine_qt`, `qconnect_event_sink_qt`. -//! §9 D10: `diagnostics_snapshot` / `QconnectDiagSnapshot` is SKIPPED (its only -//! Slint consumers — the settings Diagnostics panel + markdown export — do not -//! exist in Qt). §9 D8: the autoplay/stop unwired APIs stay ported-but-unwired, -//! exactly like the reference. +//! `diagnostics_snapshot` / `QconnectDiagSnapshot` feeds Qt's Developer +//! diagnostics panel and bug-report markdown. The autoplay/stop remote APIs +//! remain ported-but-unwired and carry targeted `allow(dead_code)` annotations; +//! they are the only dormant surface in this service. //! //! The §11.5 main.rs wiring, the B4 `QbzQConnect` bridge AND the B5/B6 //! `*_if_remote` call sites (playback_qt.rs / queue_qt.rs / playlist_qt.rs / -//! myqbz_play_qt.rs / integrations_qt.rs / cast_qt.rs) are all in place. The -//! module keeps the reference's `#![allow(dead_code)]` (same convention as -//! `toast_qt.rs`) for the deliberately-unwired surface (§9 D8 autoplay/stop). -#![allow(dead_code)] +//! myqbz_play_qt.rs / integrations_qt.rs / cast_qt.rs) are all in place. use std::sync::Arc; @@ -220,14 +217,6 @@ pub(crate) mod publish { }); } - /// `QconnectDevState.open` -> `QbzQConnect.diag_open`. Nothing in the - /// facade/sink writes this today — DeveloperSettings "Open diagnostics" - /// drives the property from QML through the bridge's `diag_set_open`. - pub(crate) fn open_diag(open: bool) { - crate::qconnect_bridge::ui(move |mut b| { - b.as_mut().set_diag_open(open); - }); - } } /// Reduced peer-renderer playback snapshot for the now-playing seek bar while @@ -454,7 +443,6 @@ async fn update_lifecycle_state_if_running( pub struct QtQconnectService { inner: Arc>, runtime: Runtime, - #[allow(dead_code)] // wired to the device-name settings UI in a later step custom_device_name: Arc>>, /// Track-id list of the queue we last pushed to the session (controller-side /// queue sync). Guards against re-pushing the same queue before the cloud's diff --git a/crates/qbz-qt/src/qconnect_transport_qt.rs b/crates/qbz-qt/src/qconnect_transport_qt.rs index 5c4305051..674a7f378 100644 --- a/crates/qbz-qt/src/qconnect_transport_qt.rs +++ b/crates/qbz-qt/src/qconnect_transport_qt.rs @@ -8,12 +8,10 @@ //! `None` before the API is initialized) — instead of Tauri's always-present //! `AppState.client`. The device-uuid + settings-DB path delegate to the shared //! `qbz_app::qconnect_identity` so the Qt and Slint builds resolve the SAME -//! identity (one device, one identity, across frontends). +//! identity (one device, one identity across upgrades). //! -//! Wired by the QConnect facade (block B3); unused until then, so the module -//! keeps the reference's `#![allow(dead_code)]` (same convention as -//! `toast_qt.rs`). -#![allow(dead_code)] +//! Wired by the QConnect facade. The module intentionally has no blanket +//! dead-code suppression: an orphaned transport seam must warn. use std::sync::Arc; @@ -200,11 +198,6 @@ pub struct QconnectMuteVolumeRequest { pub value: bool, } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct QconnectSetActiveRendererRequest { - pub renderer_id: Option, -} - /// Build a `CtrlSrvrSetPlayerState` request that seeks the remote renderer to /// `position_ms` for `current_queue_item_id` under optimistic concurrency. /// diff --git a/crates/qbz-qt/src/recently_qt.rs b/crates/qbz-qt/src/recently_qt.rs index bf09642bb..70a7958c3 100644 --- a/crates/qbz-qt/src/recently_qt.rs +++ b/crates/qbz-qt/src/recently_qt.rs @@ -235,14 +235,6 @@ pub(crate) fn prune_albums(album_ids: &[String]) -> usize { /// id, capped at [`MAX_RECENT`]) and its album at the front of the album /// history (dedup by album id, capped at [`MAX_RECENT_ALBUMS`]). /// -/// WIRED: the only caller is [`record_queue_track`], from the de-duped -/// playback track-start edge in `playback_qt.rs`. Ephemeral ids are rejected -/// here as well as there — see [`is_ephemeral_track_id`]. -#[allow(dead_code)] -pub(crate) fn record(track: RecentTrack) { - record_in_context(track, "") -} - /// The same write, told what the user was PLAYING FROM. /// /// The context cannot ride on `RecentTrack` itself, and this is the constraint diff --git a/crates/qbz-qt/src/reco_dismiss_qt.rs b/crates/qbz-qt/src/reco_dismiss_qt.rs index b06fadf4f..f95e50208 100644 --- a/crates/qbz-qt/src/reco_dismiss_qt.rs +++ b/crates/qbz-qt/src/reco_dismiss_qt.rs @@ -112,7 +112,6 @@ fn write_store(store: &DismissStore) { /// no session is bound. Id 0 (a corrupt row) never matches. // Consumed by the reco exclusion set (spec 03 §9.2 item F21), which lands with // the recommendations filter slice. -#[allow(dead_code)] pub fn ids_snapshot() -> HashSet { load_store() .artists diff --git a/crates/qbz-qt/src/search_local.rs b/crates/qbz-qt/src/search_local.rs index 1a3791faa..56a2fa97b 100644 --- a/crates/qbz-qt/src/search_local.rs +++ b/crates/qbz-qt/src/search_local.rs @@ -387,23 +387,6 @@ pub(crate) fn append_immersive_local_albums( // The fetch // --------------------------------------------------------------------------- -/// True only under HARD offline (connectivity confirmed Down). -/// -/// Exact twin of the reference's `exclude_network_folders_now`. The reason it -/// is not simply "are we offline": a manual offline TOGGLE with the LAN still -/// up must keep NAS content visible, and only a confirmed-down link justifies -/// hiding it. Per-mount accessibility checks in every browse query would be the -/// exact-but-costly alternative. -/// -/// **Scope note:** this fixes the cortinilla's call site only. Four other Qt -/// call sites still pass a literal `false` (`local_albums.rs`), so the port -/// stays inconsistent until PARITY-DEBT #82 is closed properly. Matching the -/// reference on the surface being ported is this contract's job; sweeping the -/// rest is not. -fn exclude_network_folders_now() -> bool { - crate::offline_fwd::engine().status().connectivity == qbz_app::offline_mode::Connectivity::Down -} - /// Fetch up to `limit` local-library tracks matching `query`, off the calling /// thread. /// @@ -428,7 +411,7 @@ pub(crate) async fn load_cortinilla_local( if q.chars().count() < 2 { return Vec::new(); } - let exclude_network = exclude_network_folders_now(); + let exclude_network = crate::offline_fwd::exclude_network_folders_now(); // Plex is part of the user's Local Library — the Artists/Tracks tabs union // it — so the cortinilla must include it too. The DB search only hits // `local_tracks`; the Plex cache is a separate bounded set merged here. diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 6d4a29f24..d09d7ac4c 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -3,17 +3,13 @@ //! `AudioSettingsStore` (get/set + `Player::reload_settings` / //! `Player::reinit_device` apply — the audio backend is PROTECTED: only //! these public calls), `PlaybackPreferencesStore`, the shared -//! `ui_prefs.json` (streaming quality), and the QConnect key/value DB -//! (rusqlite, same file as the Slint app). +//! `ui_prefs.json` (streaming quality), and the shared QConnect settings seam. //! //! Also owns device enumeration (`BackendManager::create_backend(type) //! .enumerate_devices()` — public) with the Tauri ALSA section grouping, //! and the cross-setting cascades from settings.rs (dac-passthrough, //! streaming-only, backend switch). //! -//! Not wired yet: -//! - The bit-perfect force-100 volume cascade. -//! //! The Detected device limit row (#638 fix 3) IS wired (2026-08-17): the probe //! and its cache moved out of the Slint binary crate into //! `qbz_app::device_cap`, `refresh_device_cap` below owns the six explicit @@ -22,9 +18,9 @@ //! (The HiFi Wizard, the JACK banner and settings export/import were on this //! list and are all shipped now — `qml/settings/DacWizardModal.qml`, //! `AudioSettings.qml:70-78` and `settings_qt/devtools.rs`.) -//! - qconnect startup/device-name persist to the SAME qconnect_settings.db -//! (wired) but do NOT drive a live QConnect service (none in the POC) — -//! they take effect on the next connection, like upstream. +//! - QConnect startup/device-name values use the transport module's one DB +//! implementation; device-name edits also update the live service cache and +//! take effect on the next connection, like upstream. use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; @@ -2059,7 +2055,9 @@ pub async fn publish_snapshot() { .iter() .position(|v| *v == audio_settings.quality_fallback_behavior) .unwrap_or(0); - let qconnect_startup = qconnect_load_startup_mode(); + let qconnect_startup = crate::qconnect_transport_qt::load_startup_mode() + .as_str() + .to_string(); let scrobble_snap = crate::integrations_qt::scrobble_settings(); let integ_ui = crate::integrations_qt::ui_snapshot(); let qconnect_startup_index = QCONNECT_STARTUP_VALUES @@ -2128,8 +2126,10 @@ pub async fn publish_snapshot() { .map(|l| qbz_i18n::t(l)) .collect(), qconnect_startup_index: qconnect_startup_index as i32, - qconnect_device_name: qconnect_load_device_name().unwrap_or_default(), - qconnect_device_name_default: qconnect_default_name(), + qconnect_device_name: crate::qconnect_transport_qt::load_persisted_device_name() + .unwrap_or_default(), + qconnect_device_name_default: + crate::qconnect_transport_qt::resolve_qconnect_friendly_name(None), album_header_gradient: pref_bool("album_header_gradient", true), compact_album_header: pref_bool("compact_album_header", false), app_background_modes: APP_BACKGROUND_LABELS @@ -2334,6 +2334,43 @@ fn apply_audio(runtime: &Arc>, apply: Apply) { crate::publish_settings(); } +fn uses_alsa_direct_hw(audio: &qbz_audio::settings::AudioSettings) -> bool { + audio.backend_type.unwrap_or_default() == AudioBackendType::Alsa + && audio.alsa_plugin.unwrap_or(AlsaPlugin::Hw) == AlsaPlugin::Hw +} + +/// Keep software gain at unity for ALSA-direct `hw` output. The public core +/// volume seam does not reconfigure the protected device/backend; the DAC owns +/// level in this mode. A QConnect peer is deliberately exempt because its +/// remote volume is the active control. +pub(crate) async fn maybe_force_bitperfect_volume( + runtime: &Arc>, +) { + let audio = match with_audio(|store| store.get_settings()) { + Ok(audio) => audio, + Err(error) => { + log::error!("[qbz-qt] re-read audio for force-100 failed: {error}"); + return; + } + }; + if !uses_alsa_direct_hw(&audio) { + return; + } + let controlling_peer = match crate::qconnect_qt::service() { + Some(service) => service.is_peer_active().await, + None => false, + }; + if controlling_peer { + return; + } + if let Err(error) = runtime.core().set_volume(1.0) { + log::error!("[qbz-qt] force bit-perfect volume to 100 failed: {error}"); + return; + } + log::info!("[qbz-qt] bit-perfect: forced local volume to 100%"); + crate::now_playing::set_volume(1.0); +} + // --------------------------------------------------------------------------- // Handlers (settings.rs handle_bool / handle_select / handle_slider / // handle_string, including the cascades) @@ -2746,6 +2783,7 @@ pub async fn settings_select(runtime: &Arc>, key: &st // that resolves that default. refresh_device_cap(runtime).await; apply_audio(runtime, Apply::Reinit); + maybe_force_bitperfect_volume(runtime).await; } "device" => { let id = { @@ -2787,6 +2825,7 @@ pub async fn settings_select(runtime: &Arc>, key: &st return; } apply_audio(runtime, Apply::Reinit); + maybe_force_bitperfect_volume(runtime).await; } "retry-behavior" => { let Some(behavior) = RETRY_BEHAVIOR_VALUES.get(index) else { @@ -2802,7 +2841,9 @@ pub async fn settings_select(runtime: &Arc>, key: &st let Some(mode) = QCONNECT_STARTUP_VALUES.get(index) else { return; }; - qconnect_persist_startup_mode(mode); + if let Some(mode) = qconnect_app::QconnectStartupMode::from_str(mode) { + crate::qconnect_transport_qt::save_startup_mode(mode); + } } // --- Appearance (phase 19) ---------------------------------------- "app-background" => { @@ -2978,8 +3019,11 @@ pub async fn settings_string(key: &str, value: String) { match key { "qconnect-device-name" => { let trimmed = value.trim().to_string(); - let stored = (!trimmed.is_empty()).then_some(trimmed.as_str()); - qconnect_persist_device_name(stored); + let stored = (!trimmed.is_empty()).then_some(trimmed); + crate::qconnect_transport_qt::persist_device_name(stored.as_deref()); + if let Some(service) = crate::qconnect_qt::service() { + service.set_custom_device_name(stored).await; + } } "myqbz-label" => save_myqbz_label(&value), "listenbrainz-token" => { @@ -3188,93 +3232,6 @@ pub async fn refresh_devices(runtime: &Arc>) { publish_snapshot().await; } -// --------------------------------------------------------------------------- -// QConnect key/value DB (same file as the Slint app) -// --------------------------------------------------------------------------- - -fn qconnect_conn() -> Option { - let db_path = qbz_app::qconnect_identity::qconnect_settings_db_path()?; - let conn = rusqlite::Connection::open(&db_path).ok()?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") - .ok()?; - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - )", - ) - .ok()?; - Some(conn) -} - -fn qconnect_load_startup_mode() -> String { - qconnect_conn() - .and_then(|conn| { - conn.query_row( - "SELECT value FROM settings WHERE key = 'startup_mode'", - [], - |row| row.get::<_, String>(0), - ) - .ok() - }) - .unwrap_or_else(|| "off".to_string()) -} - -fn qconnect_persist_startup_mode(mode: &str) { - if let Some(conn) = qconnect_conn() { - let _ = conn.execute( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('startup_mode', ?1)", - rusqlite::params![mode], - ); - } -} - -fn qconnect_load_device_name() -> Option { - qconnect_conn().and_then(|conn| { - conn.query_row( - "SELECT value FROM settings WHERE key = 'device_name'", - [], - |row| row.get::<_, String>(0), - ) - .ok() - .filter(|v: &String| !v.trim().is_empty()) - }) -} - -fn qconnect_persist_device_name(name: Option<&str>) { - if let Some(conn) = qconnect_conn() { - match name { - Some(n) => { - let _ = conn.execute( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('device_name', ?1)", - rusqlite::params![n], - ); - } - None => { - let _ = conn.execute("DELETE FROM settings WHERE key = 'device_name'", []); - } - } - } -} - -fn qconnect_default_name() -> String { - // qconnect_transport::resolve_qconnect_friendly_name(None): env var, else - // "Qbz - {hostname}". - std::env::var("QBZ_QCONNECT_DEVICE_NAME").unwrap_or_else(|_| { - let host = std::env::var("HOSTNAME") - .ok() - .filter(|s| !s.is_empty()) - .or_else(|| { - std::fs::read_to_string("/etc/hostname") - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - }) - .unwrap_or_else(|| "device".to_string()); - format!("Qbz - {host}") - }) -} - #[cfg(test)] mod local_tab_order_tests { use super::*; @@ -3329,4 +3286,20 @@ mod local_tab_order_tests { assert_eq!(large_spectrum_mode_for_tier(3, true), 3); assert_eq!(large_spectrum_mode_for_tier(4, true), 4); } + + #[test] + fn only_alsa_hw_requires_unity_software_volume() { + let mut audio = qbz_audio::settings::AudioSettings::default(); + assert!(!uses_alsa_direct_hw(&audio)); + + audio.backend_type = Some(AudioBackendType::Alsa); + audio.alsa_plugin = Some(AlsaPlugin::Hw); + assert!(uses_alsa_direct_hw(&audio)); + + audio.alsa_plugin = Some(AlsaPlugin::PlugHw); + assert!(!uses_alsa_direct_hw(&audio)); + audio.backend_type = Some(AudioBackendType::PipeWire); + audio.alsa_plugin = Some(AlsaPlugin::Hw); + assert!(!uses_alsa_direct_hw(&audio)); + } } diff --git a/crates/qbz-qt/src/settings_qt/devtools.rs b/crates/qbz-qt/src/settings_qt/devtools.rs index d9384b3f6..f20e18de2 100644 --- a/crates/qbz-qt/src/settings_qt/devtools.rs +++ b/crates/qbz-qt/src/settings_qt/devtools.rs @@ -7,14 +7,12 @@ //! the same store the Blacklist Manager view mutates, so the row cannot go //! stale behind it. //! -//! Deltas vs the Slint (reported, not hidden): -//! - Developer > "Connect diagnostics" is not shipped: the port has no live -//! Qobuz Connect service, so `QconnectDevState` has nothing to show. -//! - Developer > logs opens the log FILE (the port has no log-viewer overlay -//! with copy/upload); same for the sub-nav's "Share logs" entry. -//! - Developer > export writes the bundle straight to disk with auth -//! EXCLUDED. The Slint's `SettingsExportModal` (the include-auth gate, -//! default OFF) is not ported, so the safe default is the only behaviour. +//! Qt adaptations: +//! - QConnect diagnostics and the in-app log viewer are live; this module +//! supplies the settings actions while their documents live in +//! `diagnostics_qt` and `log_viewer_qt`. +//! - The settings export keeps the reference's include-auth gate, defaulting +//! to OFF, as an inline toggle instead of a separate modal. use qbz_app::settings::bundle::{self, ExportOptions, ExportSource}; use serde::Serialize; diff --git a/crates/qbz-qt/src/settings_qt/library.rs b/crates/qbz-qt/src/settings_qt/library.rs index c35caf1ce..600cc2b34 100644 --- a/crates/qbz-qt/src/settings_qt/library.rs +++ b/crates/qbz-qt/src/settings_qt/library.rs @@ -8,11 +8,10 @@ //! drives, cancellation and cleanup included); Plex rides `crate::local_plex` //! (wired this round) and the `QbzLocal` invokables. //! -//! Deltas vs `settings/LocalLibrarySettings.slint` (reported, not hidden): -//! - The per-folder EDIT modal (`LibFolderEditModal.slint`: alias + network -//! overrides) is not ported, so no edit affordance is shown. -//! - The Plex PIN flow (generate code / link code / open sign-in) is not -//! ported; the manual server-url + token path is what this wires. +//! The per-folder editor (alias + network overrides) and the Plex PIN sign-in +//! flow are both live in Qt. Their surfaces are `LibFolderEditModal.qml` and +//! `PlexSettings.qml`; this module owns the former's persisted state while +//! `plex_pin_qt` owns the latter's pairing lifecycle. //! //! Retired 2026-08-04: "Add folder is a path field because this port has no //! `rfd` dependency". It has had one since the MyQBZ round (`Cargo.toml:137`) diff --git a/crates/qbz-qt/src/settings_qt/offline.rs b/crates/qbz-qt/src/settings_qt/offline.rs index 743919b07..308ef1677 100644 --- a/crates/qbz-qt/src/settings_qt/offline.rs +++ b/crates/qbz-qt/src/settings_qt/offline.rs @@ -6,12 +6,9 @@ //! `qbz_lyrics::LyricsCacheDb` opened at the SAME per-user path //! `lyrics_qt.rs` binds the service to. //! -//! Deltas vs `settings/OfflineSettings.slint` (reported, not hidden): -//! - The OFFLINE CACHE group (manager / open folder / clear all) is not -//! shipped — the MANAGER VIEW behind it has no Qt counterpart yet. The old -//! reason given here ("the port never brings up OfflineCacheState") is no -//! longer true: `offline_cache_qt.rs` runs the downloads from the album -//! page and the track row. It is the view that is missing, not the engine. +//! The offline-cache group is live too (manager, open folder and clear-all), +//! but its controller lives in `offline_manager_qt`; this module only owns the +//! mode and lyrics rows serialized into the Settings document. use serde::Serialize; diff --git a/crates/qbz-qt/src/share_qt.rs b/crates/qbz-qt/src/share_qt.rs index 416d1b46a..d8feb186f 100644 --- a/crates/qbz-qt/src/share_qt.rs +++ b/crates/qbz-qt/src/share_qt.rs @@ -1,12 +1,9 @@ //! Share links + system clipboard — the Qt port of `crates/qbz/src/share.rs` -//! (lines 1-70: the URL builders and the clipboard). Driven by the artist -//! header's ⋯ → Share (`qml/views/ArtistView.qml`, the overflow menu) and, -//! since 2026-08-10, the album page's ⋯ → Share rows. +//! (lines 1-70: the URL builders and the clipboard). Driven by the artist, +//! album, label, playlist and track Share actions. //! -//! The Album.link resolver half (`share.rs:72-184`) is ported for the ALBUM -//! arm only (`albumlink_for_album` + `deezer_lookup` + the Odesli fallback); -//! `songlink_for_track` still has no Qt call site — port it with the track -//! menu that needs it. +//! Both universal-link resolvers are live: album UPC -> Album.link and track +//! ISRC -> Song.link, with the Odesli URL path as their last-resort fallback. //! //! **The `open.` vs `play.` split is deliberate, not a typo.** Track and album //! moved to `open.qobuz.com` for #514 (`share.rs:4`, `:14-16`, which records @@ -23,14 +20,11 @@ //! the whole action is one Rust seam. The four existing QML clipboard sites //! keep their idiom — this does not replace them. -// The five builders are ported as a set on purpose: they are the reference's -// complete URL surface and the album / track / label / playlist Share arms -// will reuse them verbatim when those menus are wired. Keeping the set intact +// The five builders stay together on purpose: they are the application's +// complete URL surface. Keeping the set intact // (rather than growing it one host at a time) is what stops a later arm from // re-deriving a URL and reintroducing the `open.`/`play.` mix-up that -// `qml/views/LabelView.qml` already shipped once. Same allowance the toast -// publisher takes for its unused kinds (`toast_qt.rs:27`). -#![allow(dead_code)] +// `qml/views/LabelView.qml` already shipped once. /// Canonical Qobuz track URL — the `open.qobuz.com` share form (#514). /// `share.rs:4-7`. @@ -140,8 +134,7 @@ pub(crate) fn share_artist(artist_id: String) { return; } copy_to_clipboard(qobuz_artist_url(&artist_id)); - // "Link copied" is an EXISTING msgid, present in all eight catalogues - // (crates/qbz-ui/translations/*/LC_MESSAGES/qbz-ui.po) — no new string. + // "Link copied" is an existing msgid in all eight qbz-i18n catalogues. crate::toast_qt::success(qbz_i18n::t("Link copied")); } @@ -209,6 +202,56 @@ pub(crate) fn share_album_link(album_id: String) { }); } +/// Track context menu -> "Share Qobuz link": copy + success toast. +pub(crate) fn share_track_qobuz(track_id: String) { + if track_id.is_empty() { + return; + } + copy_to_clipboard(qobuz_track_url(&track_id)); + crate::toast_qt::success(qbz_i18n::t("Link copied")); +} + +/// Track context menu -> "Share Song.link": fetch the ISRC, resolve it +/// through Deezer, and copy the universal URL. +pub(crate) fn share_track_link(track_id: String) { + if track_id.is_empty() { + return; + } + crate::toast_qt::info(qbz_i18n::t("Fetching Song.link...")); + crate::spawn(async move { + let isrc = match track_id.parse::() { + Ok(id) => crate::app() + .core() + .get_track(id) + .await + .ok() + .and_then(|track| track.isrc), + Err(_) => None, + }; + match songlink_for_track(&track_id, isrc.as_deref()).await { + Some(url) => { + copy_to_clipboard(url); + crate::toast_qt::success(qbz_i18n::t("Link copied")); + } + None => { + log::warn!("[qbz-qt] Song.link resolution failed for {track_id}"); + crate::toast_qt::error(qbz_i18n::t("Failed to copy link")); + } + } + }); +} + +/// Playlist header Share action. Local playlists have no public Qobuz URL and +/// hide the action in QML; mixed playlists remain Qobuz playlists and share +/// their catalog id normally. +pub(crate) fn share_playlist(playlist_id: String) { + if playlist_id.is_empty() || playlist_id.starts_with("local:") { + return; + } + copy_to_clipboard(qobuz_playlist_url(&playlist_id)); + crate::toast_qt::success(qbz_i18n::t("Link copied")); +} + /// Shared HTTP client settings for the share resolvers (Tauri parity: /// 10 s request / 5 s connect timeouts). fn share_http_client() -> reqwest::Client { @@ -249,6 +292,21 @@ async fn deezer_lookup(path: &str) -> Option { body.get("id").and_then(|v| v.as_u64()) } +/// Song.link page URL for a track — ISRC-first via Deezer. Odesli does not +/// accept Qobuz URLs as input, so the URL route is only a last resort when the +/// catalog response has no usable ISRC. +pub async fn songlink_for_track(track_id: &str, isrc: Option<&str>) -> Option { + if let Some(code) = isrc.map(str::trim).filter(|code| !code.is_empty()) { + if let Some(deezer_id) = deezer_lookup(&format!("track/isrc:{code}")).await { + log::info!("[qbz-qt] song.link via ISRC {code} -> deezer track {deezer_id}"); + return Some(format!("https://song.link/d/{deezer_id}")); + } + } else { + log::info!("[qbz-qt] track {track_id} has no ISRC; trying Odesli URL fallback"); + } + songlink_url(&qobuz_track_url(track_id)).await +} + /// Album.link page URL for an album — UPC-first via Deezer (#514). Qobuz /// UPCs often carry a leading zero (13-digit EAN) while Deezer stores the /// 12-digit form and does NOT match zero-padded input (verified), so a diff --git a/crates/qbz-qt/src/single_instance_qt.rs b/crates/qbz-qt/src/single_instance_qt.rs new file mode 100644 index 000000000..de14a3916 --- /dev/null +++ b/crates/qbz-qt/src/single_instance_qt.rs @@ -0,0 +1,113 @@ +//! Linux single-instance guard and warm deep-link forwarding. +//! +//! The primary owns `com.blitzfc.qbz` on the session bus. A later launch asks +//! it to present its current window, forwarding a launcher URL when present, +//! and exits. D-Bus failure is deliberately fail-open so startup is never +//! blocked by a broken or absent session bus. +#![cfg(target_os = "linux")] + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; + +use zbus::blocking::fdo::DBusProxy; +use zbus::blocking::Connection; +use zbus::fdo::{RequestNameFlags, RequestNameReply}; +use zbus::names::WellKnownName; + +const BUS_NAME: &str = "com.blitzfc.qbz"; +const OBJECT_PATH: &str = "/com/blitzfc/qbz"; +const IFACE_NAME: &str = "com.blitzfc.qbz.SingleInstance"; + +static CONN: OnceLock = OnceLock::new(); +static UI_READY: AtomicBool = AtomicBool::new(false); +static PENDING_PRESENT: AtomicBool = AtomicBool::new(false); + +struct SingleInstanceIface; + +fn present_or_defer() { + if UI_READY.load(Ordering::SeqCst) { + crate::tray_qt::present(); + } else { + PENDING_PRESENT.store(true, Ordering::SeqCst); + } +} + +#[zbus::interface(name = "com.blitzfc.qbz.SingleInstance")] +impl SingleInstanceIface { + fn present(&self) { + present_or_defer(); + } + + fn open_url(&self, url: &str) { + crate::deep_link_qt::stash(url.to_string()); + present_or_defer(); + crate::deep_link_qt::drain_pending(); + } +} + +/// Called once QbzTray has registered its Qt-thread hop. It is independent of +/// whether the optional tray icon itself is enabled. +pub(crate) fn bind_ui() { + UI_READY.store(true, Ordering::SeqCst); + if PENDING_PRESENT.swap(false, Ordering::SeqCst) { + crate::tray_qt::present(); + } +} + +/// True when this process should continue as the primary instance. +pub(crate) fn acquire_or_raise() -> bool { + match probe() { + Ok(primary) => primary, + Err(e) => { + log::warn!("[qbz-qt] single-instance probe failed ({e}); continuing"); + true + } + } +} + +fn probe() -> zbus::Result { + let conn = Connection::session()?; + conn.object_server().at(OBJECT_PATH, SingleInstanceIface)?; + let proxy = DBusProxy::new(&conn)?; + let name: WellKnownName<'_> = BUS_NAME.try_into().map_err(zbus::Error::from)?; + match proxy.request_name(name, RequestNameFlags::DoNotQueue.into())? { + RequestNameReply::PrimaryOwner | RequestNameReply::AlreadyOwner => { + let _ = CONN.set(conn); + Ok(true) + } + RequestNameReply::Exists | RequestNameReply::InQueue => { + let forwarded = match crate::deep_link_qt::take_pending() { + Some(url) => conn + .call_method( + Some(BUS_NAME), + OBJECT_PATH, + Some(IFACE_NAME), + "OpenUrl", + &url, + ) + .is_ok(), + None => false, + }; + let presented = forwarded + || conn + .call_method( + Some(BUS_NAME), + OBJECT_PATH, + Some(IFACE_NAME), + "Present", + &(), + ) + .is_ok(); + if !presented { + let _ = conn.call_method( + Some("org.mpris.MediaPlayer2.com.blitzfc.qbz"), + "/org/mpris/MediaPlayer2", + Some("org.mpris.MediaPlayer2"), + "Raise", + &(), + ); + } + Ok(false) + } + } +} diff --git a/crates/qbz-qt/src/theme_qt.rs b/crates/qbz-qt/src/theme_qt.rs index 536e198b6..d50034af2 100644 --- a/crates/qbz-qt/src/theme_qt.rs +++ b/crates/qbz-qt/src/theme_qt.rs @@ -9,8 +9,8 @@ //! (`/qbz/custom_theme.json`, the SAME file the Slint custom-theme //! editor writes — the editor and the write path live in `custom_theme_qt`, //! which also owns the path and the in-memory base this module reads). The -//! "system" registry theme resolves via the OS palette in Slint; the POC -//! maps it to the Dark palette (POC-NOTE). +//! "system" registry theme resolves via the OS palette in Slint; Qt currently +//! maps that one slug to the Dark palette, a documented parity gap. use cxx_qt_lib::QString; use qbz_theme::{Rgba, ThemeColors, ThemeId}; @@ -112,8 +112,8 @@ pub fn colors_for_slug(slug: &str) -> ThemeColors { match slug { "auto" => auto_colors(), "custom" => custom_colors(), - // POC-NOTE: "system" reads the OS palette in Slint; the POC maps it - // to the Dark registry palette. + // "system" reads the OS palette in Slint; Qt currently maps it to the + // Dark registry palette. "system" => qbz_theme::palette(ThemeId::Dark), other => ThemeId::from_slug(other) .map(qbz_theme::palette) diff --git a/crates/qbz-qt/src/toast_qt.rs b/crates/qbz-qt/src/toast_qt.rs index eea0f34c8..6e82fbf68 100644 --- a/crates/qbz-qt/src/toast_qt.rs +++ b/crates/qbz-qt/src/toast_qt.rs @@ -7,10 +7,9 @@ //! Here that falls out of the model: there is a single document and a single //! monotonic `seq`, so a publish overwrites whatever was showing and the QML //! host restarts its auto-hide timer off the `seq` change. -//! * Auto-hide by kind — success 3000 · info 3000 · warning 4000 · error 5000 · -//! buffering PERSISTENT (`toast.rs:31-40`). The delay itself lives in QML -//! (`controls/QbzToast.qml`), keyed off `seq`, so Rust never owns a timer and -//! never needs a hide round-trip. All this side publishes is `persistent`. +//! * Auto-hide by kind — success 3000 · info 3000 · warning 4000 · error 5000. +//! The delay itself lives in QML (`controls/QbzToast.qml`), keyed off `seq`, +//! so Rust never owns a timer and never needs a hide round-trip. //! * The global toggle is applied HERE, in Rust: non-error toasts honour //! `in_app_toasts`, errors ALWAYS show (`toast.rs:47-50`). The QML host does //! not read `QbzBridge.settingsJson` at all. @@ -20,12 +19,6 @@ //! a `crate::spawn` task and from inside `spawn_blocking` alike. Before //! `QbzShell.boot()` the hop is a silent no-op, same as every other bridge. //! -//! This is an API surface, not a call-site list: `warning` / `buffering` have no -//! MyQBZ producer yet (acceptance item 8 drives them from a temporary call), so -//! the unused helpers are allowed rather than removed — exactly as the reference -//! does at `toast.rs:13`. -#![allow(dead_code)] - use std::sync::atomic::{AtomicI32, Ordering}; use cxx_qt_lib::QString; @@ -35,14 +28,13 @@ use serde::Serialize; /// which is also what makes a repeat of the SAME message re-show. static SEQ: AtomicI32 = AtomicI32::new(0); -/// The five kinds `primitives/Toast.slint:20-38` renders (icon + tint per kind). +/// The four kinds with live producers (icon + tint per kind). #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum ToastKind { Success, Info, Warning, Error, - Buffering, } impl ToastKind { @@ -53,7 +45,6 @@ impl ToastKind { ToastKind::Info => "info", ToastKind::Warning => "warning", ToastKind::Error => "error", - ToastKind::Buffering => "buffering", } } } @@ -65,7 +56,6 @@ struct ToastDoc { seq: i32, kind: &'static str, message: String, - persistent: bool, } /// Publish a toast. Applies the `in_app_toasts` gate, then one `ui()` hop. @@ -83,7 +73,6 @@ pub(crate) fn show(message: impl Into, kind: ToastKind) { seq: SEQ.fetch_add(1, Ordering::Relaxed).wrapping_add(1), kind: kind.as_str(), message: message.into(), - persistent: kind == ToastKind::Buffering, }; let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into()); crate::shell_bridge::ui(move |mut b| { @@ -108,8 +97,3 @@ pub(crate) fn warning(message: impl Into) { pub(crate) fn error(message: impl Into) { show(message, ToastKind::Error); } - -/// Persistent toast (no auto-hide) — dismissed by the QML close button only. -pub(crate) fn buffering(message: impl Into) { - show(message, ToastKind::Buffering); -} diff --git a/crates/qbz-qt/src/tray_bridge.rs b/crates/qbz-qt/src/tray_bridge.rs index acd524573..e0c3ab51d 100644 --- a/crates/qbz-qt/src/tray_bridge.rs +++ b/crates/qbz-qt/src/tray_bridge.rs @@ -180,6 +180,8 @@ impl qbz_tray::QbzTray { if QT_THREAD.set(self.qt_thread()).is_err() { log::warn!("[qbz-qt] tray Qt thread already registered"); } + #[cfg(target_os = "linux")] + crate::single_instance_qt::bind_ui(); } /// D18's report-back. Flag only — it never touches a window.