feat(qt): close migration parity gaps
cx
This commit is contained in:
84 files changed
+1578
-786
No files matched your search
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
//!
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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` /
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"), "");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user