feat(qt): close migration parity gaps

cx
This commit is contained in:
vicrodh committed 2026-08-26 11:11:50 -06:00
1 parent 2bfc2b244c
commit edcb12b8a1
84 files changed
+1578 -786

No files matched your search

-16
View File
@@ -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.
@@ -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<Connection> {
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<u32>) -> Vec<AlbumPlayRow> {
/// 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<AlbumPlayRow> {
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<AlbumPlayRow> {
with_db(|conn| Some(query_on(conn, None))).unwrap_or_default()
}
+7 -15
View File
@@ -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");
+2 -5
View File
@@ -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.
//!
+2
View File
@@ -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",
+23 -28
View File
@@ -13,6 +13,7 @@
import QtQuick
import com.blitzfc.qbz
import "controls"
import "theme"
Rectangle {
@@ -154,37 +155,14 @@ 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
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()
}
}
+6 -3
View File
@@ -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)
@@ -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()
}
}
}
}
}
}
@@ -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()
}
}
+5 -21
View File
@@ -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,7 +69,6 @@ Item {
return
root.showing = true
hideTimer.stop()
if (!root.persistent)
hideTimer.restart()
}
@@ -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()
}
@@ -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 {
+2 -23
View File
@@ -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
+5
View File
@@ -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.
+34 -12
View File
@@ -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)
+9 -19
View File
@@ -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
+2 -3
View File
@@ -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
+6 -6
View File
@@ -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
+2 -2
View File
@@ -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
+8 -14
View File
@@ -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")
}
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.
//
@@ -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
+6 -11
View File
@@ -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)
}
}
}
@@ -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
@@ -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
+2 -3
View File
@@ -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,
+7 -11
View File
@@ -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.
+1 -2
View File
@@ -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
+11 -6
View File
@@ -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 {
@@ -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
+12
View File
@@ -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());
}
+3 -8
View File
@@ -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};
-5
View File
@@ -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<u64>,
@@ -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<u64> {
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<String> {
with_service(HashSet::new(), |s| {
s.get_all_albums()
+6 -8
View File
@@ -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<AppRuntime<LoggingAdapter>>,
+1 -3
View File
@@ -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() {
+8 -1
View File
@@ -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());
}
+6 -12
View File
@@ -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() {
+1 -11
View File
@@ -165,9 +165,7 @@ struct CastInner {
media_server: Option<MediaServer>,
// 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<u64>,
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<bool, String> {
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<bool, String> {
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<bool, String> {
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 {
+96
View File
@@ -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<Option<String>> = 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<String> {
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<String> = 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<String> {
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())
);
}
}
+2 -6
View File
@@ -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` /
+3 -11
View File
@@ -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::{
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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;
+1 -1
View File
@@ -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
+9 -12
View File
@@ -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<A>(runtime: &Arc<AppRuntime<A>>) -> Vec<HomeCard>
where
A: FrontendAdapter + Send + Sync + 'static,
+5 -12
View File
@@ -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
}
+1 -11
View File
@@ -476,14 +476,6 @@ pub fn active_bindings_with(overrides: &BTreeMap<String, String>) -> BTreeMap<St
map
}
/// The live active map (defaults + the persisted overrides). Part of the
/// contract §3.2 named API; the bridge resolves keys through
/// `action_for_key` (which overlays internally), so nothing calls this yet.
#[allow(dead_code)]
pub fn active_bindings() -> BTreeMap<String, String> {
active_bindings_with(&load_overrides())
}
fn action_for_shortcut<'a>(
shortcut: &str,
bindings: &'a BTreeMap<String, String>,
@@ -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,
-20
View File
@@ -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;
}
+6 -10
View File
@@ -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() {
+184
View File
@@ -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<CxxQtThread<QbzLink>> = 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();
}
}
+164
View File
@@ -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<AppRuntime<LoggingAdapter>>;
/// 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<SearchResultsPage<Track>, 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<SearchResultsPage<Album>, 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<Outcome, String> {
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"), "");
}
}
+17 -24
View File
@@ -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<GenreAlbumDetailDoc> {
/// 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<LocalTrack>)> {
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::<Vec<_>>()),
selected.iter().map(|track| track.id).collect::<Vec<_>>(),
cd.iter().map(|track| track.id).collect::<Vec<_>>()
);
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::<Vec<_>>()),
selected.iter().map(|track| track.id).collect::<Vec<_>>(),
versions[0]
.1
.iter()
.map(|track| track.id)
.collect::<Vec<_>>()
);
assert!(genre_select_version(album, 2).is_none());
assert!(genre_version_selection(album, 2).is_none());
state(|s| {
s.genre_detail_raw.remove(album);
+11 -6
View File
@@ -51,6 +51,7 @@ pub fn load_albums_blocking() -> Result<Vec<AlbumRow>, 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<Vec<AlbumRow>, 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<Vec<AlbumRow>, 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<Vec<ArtistRow>, 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<Vec<ArtistRow>, 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<Vec<ArtistRow>, 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,
+5 -13
View File
@@ -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(),
let filter_json = crate::local_state::state(|state| {
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,
);
.unwrap_or_default()
});
let (doc, selected) =
crate::local_album_actions::genre_version_selection(&request_id, index)?;
Some((
request_id,
selected,
+15 -33
View File
@@ -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
+8 -8
View File
@@ -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 <data_dir>/qbz/users/<id>/lyrics_prefs.json —
@@ -982,7 +982,7 @@ async fn resolve_target_language(runtime: &Runtime, pref: &str) -> Option<String
}
/// Toggle ON. Fails SOFT everywhere: any gap leaves the current lyrics view
/// untouched and reports through the inline notice (this shell has no toast).
/// untouched and reports through the panel-local inline notice.
async fn enable_translation(pref: String) {
let unavailable = || {
set_translation_session(false, None);
+32 -2
View File
@@ -7,6 +7,9 @@
//! async work; results hop back to Qt through the bridge's `CxxQtThread`.
mod auth_qt;
mod deep_link_qt;
#[cfg(target_os = "linux")]
mod single_instance_qt;
// Per-domain QML bridge singletons (phase 23 — the QbzBridge God-object
// split). THE PATTERN (phase-1, replicated per domain file):
// 1. one #[cxx_qt::bridge] mod per file (crate root — cxx-qt-build
@@ -40,6 +43,11 @@ mod suggestions_bridge;
// Hotkeys layer (2026-08-03 hotkeys-port contract, block B1): the
// QbzHotkeys singleton — the QML dispatcher's Rust brain (§1.1 pipeline).
mod hotkeys_bridge;
// App-wide "Open Music Link" modal and its frontend-neutral resolver adapter.
// The bridge is a singleton because the header, hotkey and launcher/deep-link
// paths all outlive the surface that triggered them.
mod link_resolver_bridge;
mod link_resolver_qt;
// Search domain (2026-08-03 cortinilla-parity contract, commit C0): the
// QbzSearch singleton, extracted from the QbzBridge god-object.
mod search_bridge;
@@ -748,8 +756,6 @@ fn on_session_entered() {
let restored = crate::settings_qt::read_pref_f32("volume")
.unwrap_or(1.0)
.clamp(0.0, 1.0);
let rt = app();
spawn(async move { playback_qt::set_volume(&rt, restored).await });
// ...and seed the UI model with the SAME value, or the engine and the
// slider disagree from the first frame.
//
@@ -793,6 +799,14 @@ fn on_session_entered() {
qconnect_service.spawn_offline_force_disconnect(&tokio_handle);
}
}
// Seed the saved level, then enforce unity gain for ALSA-direct `hw`.
// Sequencing both writes in one task prevents the asynchronous restore
// from landing after the bit-perfect correction.
let rt = app();
spawn(async move {
playback_qt::set_volume(&rt, restored).await;
settings_qt::maybe_force_bitperfect_volume(&rt).await;
});
// 3. Startup auto-connect — gated on NOT offline: the offline shell entry
// never auto-connects, and calling the spawn there would burn its
// internal once-per-process FIRED latch before a real online entry
@@ -823,6 +837,9 @@ fn enter_shell(session: auth_qt::SessionInfo) {
b.as_mut()
.set_screen(QString::from(kiosk_profile_qt::shell_screen()));
});
// Drain a cold-start launcher URL only after authenticated session entry;
// the resolver's navigation requires the Qobuz API.
deep_link_qt::set_online_session(true);
}
/// Login screen primary button / recovery banner: the system-browser OAuth
@@ -877,6 +894,7 @@ pub(crate) fn cancel_login() {
/// came up with no sidebar at all — no folders, no local playlists — and the
/// Refresh row was the only way back. See `on_session_entered`'s header.
pub(crate) fn start_offline() {
deep_link_qt::set_online_session(false);
let runtime = app();
spawn(async move {
match auth_qt::start_offline_session(&runtime).await {
@@ -917,6 +935,7 @@ pub(crate) fn do_logout() {
// Flip this before the async teardown: a delayed scrobble firing anywhere
// in that window must already obey the logged-out opt-out.
integrations_qt::set_qobuz_authenticated(false);
deep_link_qt::set_online_session(false);
let runtime = app();
spawn(async move {
// A connected renderer keeps playing after the session ends unless it
@@ -2367,6 +2386,11 @@ pub(crate) fn apply_language(code: String) {
// standing in its error state — which is also the only state the owner's
// own account can produce, so it is the one worth getting right.
purchases_qt::republish();
// Browse and Label also embed translated strings inside their JSON
// documents (playlist-count plurals, jump tabs and the Unknown bucket).
// `trRev` cannot reach those values, so refresh their live snapshots too.
browse_qt::republish_for_language();
label_qt::republish_for_language();
let (view, id) = LAST_DETAIL.lock().unwrap().clone();
if !id.is_empty() {
match view.as_str() {
@@ -3139,6 +3163,12 @@ pub(crate) fn arm_hard_exit_watchdog(source: &'static str) {
fn main() {
qbz_log::install("info");
deep_link_qt::capture_argv();
#[cfg(target_os = "linux")]
if !single_instance_qt::acquire_or_raise() {
log::info!("[qbz-qt] another instance owns the session bus name; exiting");
return;
}
// Link anchor for the hand-written QAbstractListModel. Its QML singleton
// registration itself runs at QCoreApplication startup.
local_tracks_model_qt::register_qml_model();
+1 -5
View File
@@ -151,12 +151,8 @@ pub fn fetch_local_and_plex(artist_name: &str) -> Vec<Candidate> {
"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,
+2 -6
View File
@@ -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
+112 -34
View File
@@ -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<ResolvedItem> {
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<qbz_offline_cache::CachedTrackInfo> = 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::<u64>().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<AppRuntime<LoggingAdapter>>) {
}
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<AppRuntime<LoggingAdapter>>) {
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<AppRuntime<LoggingAdapter>>, 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<AppRuntime<LoggingAdapter>>, 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<String> = 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}");
-8
View File
@@ -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],
+142 -10
View File
@@ -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<u64>,
cached_album_ids: HashSet<String>,
plex_local_rows: HashSet<i64>,
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::<u64>()
.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::<i64>() {
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<i64> = items
.iter()
.filter(|item| item.source == AlbumSource::Local && item.item_type == ItemType::Track)
.filter_map(|item| item.source_item_id.parse::<i64>().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<MixtapeCollection>,
) -> Vec<MixtapeCollection> {
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<MixtapeCollection> = match grid {
let mut rows: Vec<MixtapeCollection> = 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;
});
-4
View File
@@ -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<u32>, sample_rate: Option<f64>, governed: bool) {
crate::quality_state::seed_track(bit_depth, sample_rate, governed);
}
@@ -406,7 +405,6 @@ pub fn set_catalog_quality(bit_depth: Option<u32>, sample_rate: Option<f64>, 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);
}
+22 -8
View File
@@ -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<OfflineModeEngine> {
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<PathBuf> {
/// 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}");
+5 -20
View File
@@ -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<Quality> = RwLock::new(Quality::UltraHiRes);
static STREAMING_QUALITY: RwLock<Quality> = 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.
+3 -5
View File
@@ -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]
-1
View File
@@ -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,
@@ -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<String>,
pub offline_only: bool,
pub track_count: u32,
pub is_favorite: bool,
+3 -3
View File
@@ -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);
}
+2 -4
View File
@@ -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;
+2 -2
View File
@@ -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};
+5 -17
View File
@@ -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<Mutex<QtQconnectInner>>,
runtime: Runtime,
#[allow(dead_code)] // wired to the device-name settings UI in a later step
custom_device_name: Arc<tokio::sync::RwLock<Option<String>>>,
/// 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
+3 -10
View File
@@ -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<i32>,
}
/// Build a `CtrlSrvrSetPlayerState` request that seeks the remote renderer to
/// `position_ms` for `current_queue_item_id` under optimistic concurrency.
///
-8
View File
@@ -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
-1
View File
@@ -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<u64> {
load_store()
.artists
+1 -18
View File
@@ -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.
+74 -101
View File
@@ -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<AppRuntime<LoggingAdapter>>, 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<AppRuntime<LoggingAdapter>>,
) {
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<AppRuntime<LoggingAdapter>>, 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<AppRuntime<LoggingAdapter>>, 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<AppRuntime<LoggingAdapter>>, 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<AppRuntime<LoggingAdapter>>) {
publish_snapshot().await;
}
// ---------------------------------------------------------------------------
// QConnect key/value DB (same file as the Slint app)
// ---------------------------------------------------------------------------
fn qconnect_conn() -> Option<rusqlite::Connection> {
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<String> {
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));
}
}
+6 -8
View File
@@ -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;
+4 -5
View File
@@ -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`)
+3 -6
View File
@@ -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;
+73 -15
View File
@@ -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::<u64>() {
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<u64> {
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<String> {
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
+113
View File
@@ -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<Connection> = 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<bool> {
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)
}
}
}
+4 -4
View File
@@ -9,8 +9,8 @@
//! (`<data_dir>/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)
+4 -20
View File
@@ -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<String>, 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<String>) {
pub(crate) fn error(message: impl Into<String>) {
show(message, ToastKind::Error);
}
/// Persistent toast (no auto-hide) — dismissed by the QML close button only.
pub(crate) fn buffering(message: impl Into<String>) {
show(message, ToastKind::Buffering);
}
+2
View File
@@ -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.