feat(qt): port Qobuz Connect — service, flyout, routing and touchpoints
Full-parity port of the Slint QConnect integration per the contract at qbz-nix-docs/qt-frontend/2026-08-01-qconnect-port/00-CONTRACT.md (three adversarial review rounds on the spec, §12 trap sign-off PASS on both halves, RFB-verified incl. a real cloud connect, live session owner-tested). The shared crates are consumed verbatim — zero backend changes. - adapter quartet: qconnect_transport_qt (credentials, identity on the shared qconnect_settings.db), qconnect_engine_qt (start_track_stream fallback chain + feeder helpers + CMAF cursor sync), qconnect_event_sink_qt, qconnect_qt (the facade: connect/disconnect recipe, controller routing, echo guards, offline parking B12, startup auto-connect) - QbzQConnect bridge singleton + on_session_entered wiring; one shared flyout for both bars (tooltip on the large bar only), Developer > Connect diagnostics modal, device-row glyphs, settings rows live - poll loop: peer-reflection branch before cast, ~2 s renderer reports, sync-on-add on every Qobuz-castable add path - routing: cast → QConnect → local (mute QConnect-first), play arms after the set_queue funnel, reorder cloud arm with no local fallback, volume/mute/seek remote-first - touchpoints: lyrics remote anchor, scrobble guarded / Discord unguarded, cast suspend/resume, volume-lock lifted when remote km
No files matched your search
@@ -6618,6 +6618,7 @@ dependencies = [
|
||||
"cxx-qt-build",
|
||||
"cxx-qt-lib",
|
||||
"dirs 5.0.1",
|
||||
"futures-util",
|
||||
"image",
|
||||
"log",
|
||||
"open",
|
||||
@@ -6636,12 +6637,15 @@ dependencies = [
|
||||
"qbz-mixtape",
|
||||
"qbz-models",
|
||||
"qbz-offline-cache",
|
||||
"qbz-player",
|
||||
"qbz-playlist-import",
|
||||
"qbz-plex",
|
||||
"qbz-qobuz",
|
||||
"qbz-source",
|
||||
"qbz-text-utils",
|
||||
"qbz-theme",
|
||||
"qconnect-app",
|
||||
"qconnect-transport-ws",
|
||||
"rand 0.10.1",
|
||||
"reqwest",
|
||||
"rfd",
|
||||
@@ -6650,6 +6654,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
"uuid 1.22.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -57,6 +57,24 @@ qbz-theme = { path = "../qbz-theme" }
|
||||
qbz-integrations = { path = "../qbz-integrations" }
|
||||
# Last.fm "similar albums" row on the album page (external_reco_qt.rs).
|
||||
qbz-external-reco = { path = "../qbz-external-reco" }
|
||||
# Qobuz Connect (2026-08-01 Qt-port contract): the shared session/renderer
|
||||
# orchestration and the WS transport, consumed VERBATIM — backend untouchable,
|
||||
# same edges as the Slint crate (crates/qbz/Cargo.toml:87-88). Driven by
|
||||
# src/qconnect_transport_qt.rs + src/qconnect_engine_qt.rs (+ the B3/B4 facade
|
||||
# and sink).
|
||||
qconnect-app = { path = "../qconnect-app" }
|
||||
qconnect-transport-ws = { path = "../qconnect-transport-ws" }
|
||||
# Type-level edge only, sanctioned by contract §1.1: the QConnect renderer
|
||||
# engine trait names `qbz_player::PlaybackState`, and the engine's feeder
|
||||
# reaches the protected player seams THROUGH `runtime.core().player()` — the
|
||||
# same edge the Slint crate has (crates/qbz/Cargo.toml:85). No
|
||||
# qbz-player/qbz-audio internals are touched (TRACK-RULES §7).
|
||||
qbz-player = { path = "../qbz-player" }
|
||||
# QConnect: uuid v4 for controller action ids (the B3 facade / B4 sink — same
|
||||
# as crates/qbz/Cargo.toml:90); futures-util for the engine's HTTP feeder
|
||||
# (StreamExt over the response byte stream).
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
futures-util = { workspace = true }
|
||||
async-trait = "0.1"
|
||||
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
|
||||
@@ -51,7 +51,7 @@ fn main() {
|
||||
// (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain
|
||||
// modules and must NOT be listed — only files that declare a
|
||||
// #[cxx_qt::bridge] mod belong in this array.
|
||||
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs"],
|
||||
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs"],
|
||||
qml_files: &[
|
||||
"qml/LoginScreen.qml",
|
||||
"qml/Main.qml",
|
||||
@@ -132,6 +132,11 @@ fn main() {
|
||||
"qml/shell/SidebarNowPlayingDock.qml",
|
||||
"qml/shell/AudioSettingsMenu.qml",
|
||||
"qml/shell/ViewModeMenu.qml",
|
||||
// Qobuz Connect (2026-08-01 contract §2): the ONE shared
|
||||
// device flyout both bars mount + the diagnostics modal
|
||||
// AppShell mounts last.
|
||||
"qml/shell/QconnectFlyout.qml",
|
||||
"qml/shell/QconnectDevModal.qml",
|
||||
"qml/theme/QbzIcon.qml",
|
||||
"qml/theme/QbzScrollBar.qml",
|
||||
"qml/theme/QbzSpinner.qml",
|
||||
|
||||
@@ -106,6 +106,12 @@ ApplicationWindow {
|
||||
// hop; QbzSession.boot additionally fires the app boot sequence).
|
||||
Component.onCompleted: {
|
||||
QbzSession.boot()
|
||||
// Qobuz Connect (2026-08-01 contract §2/§8). Booted right after
|
||||
// QbzSession because QbzSession.boot fires the whole app boot
|
||||
// sequence, whose shell entry runs the §11.5 service wiring — the
|
||||
// badge/devices publishes it makes must find the Qt-thread hop
|
||||
// already registered or they are dropped silently.
|
||||
QbzQConnect.boot()
|
||||
QbzShell.boot()
|
||||
QbzPlayer.boot()
|
||||
QbzQueue.boot()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
|
||||
<path d="M12 18h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 270 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="16" height="20" x="4" y="2" rx="2" />
|
||||
<circle cx="12" cy="14" r="4" />
|
||||
<path d="M12 6h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 297 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
|
||||
<path d="M12 18h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 270 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="16" height="20" x="4" y="2" rx="2" />
|
||||
<circle cx="12" cy="14" r="4" />
|
||||
<path d="M12 6h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 297 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
|
||||
<path d="M12 18h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 270 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="16" height="20" x="4" y="2" rx="2" />
|
||||
<circle cx="12" cy="14" r="4" />
|
||||
<path d="M12 6h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 297 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
|
||||
<path d="M12 18h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 270 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="16" height="20" x="4" y="2" rx="2" />
|
||||
<circle cx="12" cy="14" r="4" />
|
||||
<path d="M12 6h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 297 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
|
||||
<path d="M12 18h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 270 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="16" height="20" x="4" y="2" rx="2" />
|
||||
<circle cx="12" cy="14" r="4" />
|
||||
<path d="M12 6h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 297 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
|
||||
<path d="M12 18h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 270 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="16" height="20" x="4" y="2" rx="2" />
|
||||
<circle cx="12" cy="14" r="4" />
|
||||
<path d="M12 6h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 297 B |
@@ -1,12 +1,9 @@
|
||||
// Settings > Developer — the QML port of crates/qbz-ui/ui/settings/
|
||||
// DeveloperSettings.slint.
|
||||
//
|
||||
// Shipped, in the Slint's order: LOGS · SETTINGS PORTABILITY.
|
||||
// Shipped, in the Slint's order: QOBUZ CONNECT · LOGS · SETTINGS PORTABILITY.
|
||||
//
|
||||
// Deltas vs the Slint (no dead rows):
|
||||
// - "QOBUZ CONNECT / Connect diagnostics" is absent: this port has no live
|
||||
// Qobuz Connect service, so the diagnostics modal would have no session to
|
||||
// describe.
|
||||
// - LOGS opens the on-disk log FILE (qbz_log's file sink) instead of the
|
||||
// in-app log viewer overlay (copy / redact / upload), which is not ported.
|
||||
// - "Export settings…" writes the bundle immediately with auth EXCLUDED; the
|
||||
@@ -30,6 +27,19 @@ Column {
|
||||
|
||||
spacing: 4
|
||||
|
||||
// ========================== QOBUZ CONNECT ============================
|
||||
GroupHeader { text: QbzSession.tr("QOBUZ CONNECT", QbzSession.trRev) }
|
||||
SettingRow {
|
||||
label: QbzSession.tr("Connect diagnostics", QbzSession.trRev)
|
||||
description: QbzSession.tr("Live session topology and a rolling event log for debugging Qobuz Connect at runtime.", QbzSession.trRev)
|
||||
SettingsButton {
|
||||
text: QbzSession.tr("Open diagnostics", QbzSession.trRev)
|
||||
onClicked: QbzQConnect.diagSetOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSpacer { }
|
||||
|
||||
// ============================== LOGS =================================
|
||||
GroupHeader { text: QbzSession.tr("LOGS", QbzSession.trRev) }
|
||||
SettingRow {
|
||||
|
||||
@@ -78,6 +78,11 @@ Rectangle {
|
||||
// Mode-aware height (AppShell.slint:396): Small collapses to one
|
||||
// header-tall row; New/Classic/Large keep the full 112px.
|
||||
height: QbzShell.npbMode === 2 ? theme.npbSmallHeight : theme.npbLargeHeight
|
||||
// The shared hover-tooltip overlay (declared further down — id
|
||||
// references resolve at completion). Only the FULL bar consumes it
|
||||
// (the Qobuz Connect button's "Qobuz Connect: On/Off" bubble); the
|
||||
// small bar's button has no tooltip in the reference.
|
||||
tooltip: tooltipOverlay
|
||||
}
|
||||
|
||||
Sidebar {
|
||||
@@ -620,4 +625,15 @@ Rectangle {
|
||||
id: tooltipOverlay
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
// Qobuz Connect diagnostics modal (DeveloperSettings > QOBUZ CONNECT) —
|
||||
// mounted LAST, mirroring QconnectDevModal.slint's topmost mount in
|
||||
// AppShell.slint. Ordering against the tooltip above is a non-issue: this
|
||||
// is a Popup parented to Overlay.overlay (the CastPicker pattern), so it
|
||||
// renders in the overlay layer above EVERY plain Item in this tree —
|
||||
// the tooltip's "LAST child so no neighbour can cover it" invariant is
|
||||
// about Items, and no Item was added after it. Self-gates on
|
||||
// QbzQConnect.diagOpen, so while closed it is an invisible,
|
||||
// non-interactive Item.
|
||||
QconnectDevModal { }
|
||||
}
|
||||
@@ -9,8 +9,30 @@ import com.blitzfc.qbz
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// The shell's shared hover-tooltip overlay (controls/QbzTooltip.qml),
|
||||
// fed in by AppShell (same pattern as Sidebar.tooltip). Forwarded into
|
||||
// the loaded bar below. Only the FULL bar consumes it — the Qobuz
|
||||
// Connect button's "Qobuz Connect: On/Off" bubble (contract §8: the
|
||||
// small bar's button has no tooltip in the reference).
|
||||
property Item tooltip: null
|
||||
|
||||
Loader {
|
||||
id: barLoader
|
||||
anchors.fill: parent
|
||||
source: QbzShell.npbMode === 2 ? "NowPlayingBarSmall.qml" : "PlayerBar.qml"
|
||||
}
|
||||
|
||||
// Same shape as AppShell's viewLoader "kind" Binding: applies the moment
|
||||
// the item exists, re-applies on a mode switch, RestoreNone because the
|
||||
// target is DESTROYED on unload. The `when` source guard keeps it off
|
||||
// NowPlayingBarSmall, which declares no `tooltip` property (a grouped
|
||||
// assignment against a missing property would fail the load).
|
||||
Binding {
|
||||
target: barLoader.item
|
||||
property: "tooltip"
|
||||
value: root.tooltip
|
||||
when: barLoader.item !== null
|
||||
&& barLoader.source.toString().indexOf("PlayerBar.qml") !== -1
|
||||
restoreMode: Binding.RestoreNone
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,10 @@
|
||||
// is AudioStamp.qml — the inline 2-row stamp (quality line over the backend /
|
||||
// mode LEDs) that PlayerBarSmall.slint mounts, shared with the Large bar.
|
||||
//
|
||||
// Connect / Settings / ViewMode are still inert visual replicas where no Qt
|
||||
// invokable exists (TODO comments at the call sites); Volume, Queue, Shuffle,
|
||||
// Repeat, Mute, Lyrics and the add flyout are live. Cast opens
|
||||
// Connect is LIVE (the golden ConnectButton opens shell/QconnectFlyout.qml —
|
||||
// NO tooltip, the reference asymmetry of contract §8); Settings / ViewMode
|
||||
// are live too. Volume, Queue, Shuffle, Repeat, Mute, Lyrics and the add
|
||||
// flyout are live. Cast opens
|
||||
// shell/CastPicker.qml (QbzCast — discovery, connect/disconnect, the
|
||||
// per-device quality cap) and lights while a renderer is connected. Track
|
||||
// Info (the (i) button and the song-card title) opens
|
||||
@@ -47,6 +48,15 @@ Rectangle {
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// Volume lock, both halves (contract §11.3 — the same `vol-locked`
|
||||
// formula PlayerBar.slint:152-158 carries and PlayerBar.qml repeats):
|
||||
// the ALSA-Direct hw derivation inside npVolumeLocked is LIFTED while a
|
||||
// peer owns playback; npRemoteVolumeLocked is the sink-pushed "peer
|
||||
// disallows remote volume" half.
|
||||
readonly property bool volLocked:
|
||||
(QbzPlayer.npVolumeLocked && !QbzPlayer.npIsRemote)
|
||||
|| QbzPlayer.npRemoteVolumeLocked
|
||||
|
||||
// Responsive side fraction — IDENTICAL to PlayerBarSmall's mechanism:
|
||||
// LEFT and RIGHT columns share this fraction so the CENTER transport
|
||||
// column lands PLAY on the window centre. The breakpoints (39/30/25 at
|
||||
@@ -383,11 +393,38 @@ Rectangle {
|
||||
gapRight: 8
|
||||
}
|
||||
|
||||
// Qobuz Connect.
|
||||
// TODO(qt-bridge): no qconnect state/toggle exposed
|
||||
// (Slint: NowPlayingState.qconnect-connected +
|
||||
// qconnect-toggle()). Rendered 1:1, inert.
|
||||
QbzIconButton { name: "monitor-speaker" }
|
||||
// Qobuz Connect — the same golden ConnectButton as the
|
||||
// full bar (Slint keeps a file-private copy in each bar,
|
||||
// PlayerBarSmall.slint:52-55), minus the tooltip: the
|
||||
// reference binds NO tooltip on the small bar's button
|
||||
// (contract §8). Opens the shared flyout.
|
||||
Rectangle {
|
||||
id: smallQconnectBtn
|
||||
readonly property bool qcActive: QbzQConnect.qconnectConnected
|
||||
readonly property color gold: "#e0b341"
|
||||
width: 32
|
||||
height: 32
|
||||
radius: theme.radiusSm
|
||||
color: qcActive ? Qt.rgba(gold.r, gold.g, gold.b, 0.16)
|
||||
: (smallQcArea.containsMouse ? theme.surfaceHover : "transparent")
|
||||
border.width: qcActive ? 1 : 0
|
||||
border.color: Qt.rgba(gold.r, gold.g, gold.b, 0.45)
|
||||
QbzIcon {
|
||||
name: "monitor-speaker"
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.centerIn: parent
|
||||
tintName: smallQconnectBtn.qcActive ? "amber"
|
||||
: smallQcArea.containsMouse ? "textPrimary" : "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
id: smallQcArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: smallQcFlyout.openBelowRight(smallQconnectBtn)
|
||||
}
|
||||
}
|
||||
// Cast (Chromecast / DLNA) — opens the picker modal and,
|
||||
// with it, device discovery (PlayerBarSmall.slint:596-611:
|
||||
// picker-open = true + CastActions.open()); lit while a
|
||||
@@ -430,16 +467,17 @@ Rectangle {
|
||||
Item { width: 4; height: 1 }
|
||||
|
||||
// Volume — mute icon + (WIDE) inline horizontal slider.
|
||||
// Both gate on root.volLocked (§11.3), like the full bar.
|
||||
QbzIconButton {
|
||||
name: QbzPlayer.npMuted ? "volume-x" : "volume-2"
|
||||
btnEnabled: !QbzPlayer.npVolumeLocked
|
||||
btnEnabled: !root.volLocked
|
||||
active: QbzPlayer.npMuted
|
||||
onClicked: QbzPlayer.toggleMute()
|
||||
}
|
||||
// WIDE: the shared QbzSlider (PlayerBarSmall mounts the
|
||||
// same primitive at 72px on the 0..1000 scale).
|
||||
QbzSlider {
|
||||
enabled: !QbzPlayer.npVolumeLocked
|
||||
enabled: !root.volLocked
|
||||
visible: root.volumeWide
|
||||
width: 72
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -481,6 +519,11 @@ Rectangle {
|
||||
}
|
||||
AudioSettingsMenu { id: smallAudioMenu; doc: root.settingsDoc }
|
||||
|
||||
// Qobuz Connect device flyout — the ONE shared component both bars mount
|
||||
// (contract §8; the Slint `qconnect-menu` PopupWindow). Opened below-
|
||||
// right of the Connect button.
|
||||
QconnectFlyout { id: smallQcFlyout }
|
||||
|
||||
// "Add to…" flyout behind the transport "+" (TransportControls.slint's
|
||||
// add-menu) — same seven entries, order and icons as the full bar.
|
||||
CardMenu {
|
||||
|
||||
@@ -44,11 +44,12 @@
|
||||
// Info — the (i) button and the song-card title open shell/TrackInfoModal.qml
|
||||
// (needs the QbzAlbum.openTrackInfo glue; see that file's header). Cast opens
|
||||
// shell/CastPicker.qml (QbzCast — discovery, connect/disconnect, the
|
||||
// per-device quality cap) and lights while a renderer is connected.
|
||||
// Inert (TODO comments at the call sites): Connect (the QConnect device
|
||||
// flyout) and add-to-playlist. add-to-mixtape is LIVE since the MyQBZ domain
|
||||
// landed (QbzMyQbzAdd.open). The volume LOCK (ALSA hw / remote) is still not
|
||||
// enforced.
|
||||
// per-device quality cap) and lights while a renderer is connected. Qobuz
|
||||
// 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).
|
||||
//
|
||||
// SIZE (project rule): the inline SongCard / TransportControls / FavToggle
|
||||
// components moved out to shell/SongCard.qml, shell/TransportControls.qml and
|
||||
@@ -68,6 +69,25 @@ Rectangle {
|
||||
readonly property bool largeActive: QbzShell.npbMode === 3 && QbzShell.sidebarState === 0
|
||||
readonly property bool isClassic: QbzShell.npbMode === 1
|
||||
|
||||
// The shell's shared hover-tooltip overlay (controls/QbzTooltip.qml),
|
||||
// fed in through NowPlayingBar.qml's Binding. Consumed by the Qobuz
|
||||
// Connect button's "Qobuz Connect: On/Off" bubble — the ONE tooltip the
|
||||
// reference binds on this bar (PlayerBar.slint:405-407; the small bar's
|
||||
// button has none, contract §8).
|
||||
property Item tooltip: null
|
||||
|
||||
// Volume lock, both halves (contract §11.3 = PlayerBar.slint:152-158's
|
||||
// `vol-locked`): npVolumeLocked carries the ALSA-Direct hw derivation
|
||||
// INSIDE it, and that local bit-perfect lock is LIFTED when a peer owns
|
||||
// playback (the `&& !npIsRemote` term) so the user CAN adjust the remote
|
||||
// renderer; npRemoteVolumeLocked is the sink-pushed "peer disallows
|
||||
// remote volume" half. The natural misread (`!npVolumeLocked &&
|
||||
// !npIsRemote && ...`) would disable the slider for EVERY peer, breaking
|
||||
// §7 volume routing to volume-ALLOWING peers.
|
||||
readonly property bool volLocked:
|
||||
(QbzPlayer.npVolumeLocked && !QbzPlayer.npIsRemote)
|
||||
|| QbzPlayer.npRemoteVolumeLocked
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// Responsive zones (PlayerBar.slint). Classic fixes col-side at 0.324;
|
||||
@@ -442,14 +462,60 @@ Rectangle {
|
||||
onClicked: QbzCast.openPicker()
|
||||
}
|
||||
|
||||
// Qobuz Connect — inert (device flyout out of scope).
|
||||
// monitor-speaker is the icon both Slint bars use.
|
||||
QbzIconButton {
|
||||
name: "monitor-speaker"
|
||||
// Qobuz Connect — the Slint ConnectButton (PlayerBar.
|
||||
// slint:32-77): monitor-speaker glyph, GOLDEN over a soft
|
||||
// golden tint whenever a session is on — whether qbz is
|
||||
// the renderer or controlling a peer (not just is-remote).
|
||||
// Opens the shared flyout (QconnectFlyout.qml). This is
|
||||
// NOT a QbzIconButton: the reference's active state is a
|
||||
// gold tint + 1px border + gold glyph, which the shared
|
||||
// button's accent-glyph `active` cannot express. The
|
||||
// "amber" tint IS the reference's gold (#e0b341).
|
||||
Rectangle {
|
||||
id: qconnectBtn
|
||||
readonly property bool qcActive: QbzQConnect.qconnectConnected
|
||||
readonly property color gold: "#e0b341"
|
||||
width: 32
|
||||
height: 32
|
||||
radius: theme.radiusSm
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// TODO(qt-bridge): no qconnect state/toggle exposed
|
||||
// (Slint: NowPlayingState.qconnect-connected +
|
||||
// qconnect-toggle()). Rendered 1:1, inert.
|
||||
color: qcActive ? Qt.rgba(gold.r, gold.g, gold.b, 0.16)
|
||||
: (qcArea.containsMouse ? theme.surfaceHover : "transparent")
|
||||
border.width: qcActive ? 1 : 0
|
||||
border.color: Qt.rgba(gold.r, gold.g, gold.b, 0.45)
|
||||
QbzIcon {
|
||||
name: "monitor-speaker"
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.centerIn: parent
|
||||
tintName: qconnectBtn.qcActive ? "amber"
|
||||
: qcArea.containsMouse ? "textPrimary" : "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
id: qcArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (root.tooltip)
|
||||
root.tooltip.hide("qconnect")
|
||||
qcFlyout.openBelowRight(qconnectBtn)
|
||||
}
|
||||
// The ONE bar tooltip the reference binds (always
|
||||
// shows on hover, even while connected — Slint
|
||||
// PlayerBar.slint:64-74 says why out loud).
|
||||
onContainsMouseChanged: {
|
||||
if (!root.tooltip)
|
||||
return
|
||||
if (containsMouse)
|
||||
root.tooltip.showAbove(qconnectBtn, "qconnect",
|
||||
QbzQConnect.qconnectConnected
|
||||
? QbzSession.tr("Qobuz Connect: On", QbzSession.trRev)
|
||||
: QbzSession.tr("Qobuz Connect: Off", QbzSession.trRev))
|
||||
else
|
||||
root.tooltip.hide("qconnect")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lyrics.
|
||||
@@ -484,16 +550,18 @@ Rectangle {
|
||||
|
||||
// Volume: mute · slider · −/+ steppers (the steppers are
|
||||
// the Tauri volume-step buttons, gated by the appearance
|
||||
// preference like the Slint bar).
|
||||
// preference like the Slint bar). All four controls gate
|
||||
// on root.volLocked (§11.3 — the local ALSA-hw lock lifts
|
||||
// under a peer; a volume-disallowing peer locks).
|
||||
QbzIconButton {
|
||||
name: QbzPlayer.npMuted ? "volume-x" : "volume-2"
|
||||
btnEnabled: !QbzPlayer.npVolumeLocked
|
||||
btnEnabled: !root.volLocked
|
||||
active: QbzPlayer.npMuted
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzPlayer.toggleMute()
|
||||
}
|
||||
QbzSlider {
|
||||
enabled: !QbzPlayer.npVolumeLocked
|
||||
enabled: !root.volLocked
|
||||
width: 81
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
minimum: 0
|
||||
@@ -510,7 +578,7 @@ Rectangle {
|
||||
name: "minus"
|
||||
iconSize: 15
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
btnEnabled: !QbzPlayer.npVolumeLocked
|
||||
btnEnabled: !root.volLocked
|
||||
onClicked: QbzPlayer.setVolume(Math.max(0.0, QbzPlayer.npVolume - 0.05))
|
||||
}
|
||||
QbzIconButton {
|
||||
@@ -518,7 +586,7 @@ Rectangle {
|
||||
name: "plus"
|
||||
iconSize: 15
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
btnEnabled: !QbzPlayer.npVolumeLocked
|
||||
btnEnabled: !root.volLocked
|
||||
onClicked: QbzPlayer.setVolume(Math.min(1.0, QbzPlayer.npVolume + 0.05))
|
||||
}
|
||||
|
||||
@@ -546,6 +614,11 @@ Rectangle {
|
||||
// not re-parse settingsJson on every open.
|
||||
AudioSettingsMenu { id: audioMenu; doc: root.settingsDoc }
|
||||
|
||||
// Qobuz Connect device flyout — the ONE shared component both bars mount
|
||||
// (contract §8; the Slint `qconnect-menu` PopupWindow, PlayerBar.slint:
|
||||
// 412-642). Opened below-right of the Connect button.
|
||||
QconnectFlyout { id: qcFlyout }
|
||||
|
||||
// "Add to…" flyout behind the transport "+" (TransportControls.slint's
|
||||
// add-menu), on the shared CardMenu surface. Same seven entries, same
|
||||
// order, same icons.
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// Qobuz Connect — DEV diagnostics modal, the QML port of
|
||||
// crates/qbz-ui/ui/shell/QconnectDevModal.slint. Opened from Settings >
|
||||
// Developer > QOBUZ CONNECT > "Open diagnostics" (QbzQConnect.diagSetOpen).
|
||||
// Shows the live status block (session topology / renderer roles / queue)
|
||||
// and the rolling 150-line event log, both push-driven off QbzQConnect
|
||||
// (diagStatus / diagLogText); "Clear" empties the log (diagClear).
|
||||
//
|
||||
// The four UI strings here are HARDCODED ENGLISH, 1:1 with the reference
|
||||
// (contract §9 D3 — the Slint hardcodes them too; no msgids exist).
|
||||
//
|
||||
// Mechanism: the CastPicker.qml pattern — a modal Popup parented to
|
||||
// Overlay.overlay with its own #000000bf scrim (the default modal dimmer
|
||||
// would darken it twice). QbzQConnect.diagOpen is the single source of
|
||||
// truth, mirrored onto the Popup both ways. Mounted LAST in AppShell.qml
|
||||
// (topmost), mirroring the Slint mount (AppShell.slint:841,869).
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import com.blitzfc.qbz
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
|
||||
Popup {
|
||||
id: root
|
||||
|
||||
parent: Overlay.overlay
|
||||
x: 0
|
||||
y: 0
|
||||
width: parent ? parent.width : 0
|
||||
height: parent ? parent.height : 0
|
||||
padding: 0
|
||||
z: 3000
|
||||
modal: true
|
||||
dim: false
|
||||
closePolicy: Popup.CloseOnEscape
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// QbzQConnect.diagOpen is the single source of truth (DeveloperSettings
|
||||
// sets it true; our own close hands the news back, same as CastPicker).
|
||||
Connections {
|
||||
target: QbzQConnect
|
||||
function onDiagOpenChanged() {
|
||||
if (QbzQConnect.diagOpen)
|
||||
root.open()
|
||||
else
|
||||
root.close()
|
||||
}
|
||||
}
|
||||
onClosed: {
|
||||
if (QbzQConnect.diagOpen)
|
||||
QbzQConnect.diagSetOpen(false)
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
QbzQConnect.diagSetOpen(false)
|
||||
}
|
||||
|
||||
background: Rectangle { color: "#bf000000" }
|
||||
|
||||
contentItem: Item {
|
||||
|
||||
// Scrim — click closes.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.dismiss()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
width: Math.min(root.width - 80, 600)
|
||||
height: Math.min(root.height - 80, 640)
|
||||
x: Math.round((parent.width - width) / 2)
|
||||
y: Math.round((parent.height - height) / 2)
|
||||
radius: theme.radiusMd
|
||||
color: theme.surfaceCard
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
clip: true
|
||||
|
||||
// Swallow clicks so they don't reach the scrim.
|
||||
MouseArea { anchors.fill: parent }
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 14
|
||||
|
||||
// Header.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 28
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.right: closeBtn.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Qobuz Connect — Diagnostics"
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightSemibold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Rectangle {
|
||||
id: closeBtn
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 28
|
||||
height: 28
|
||||
radius: theme.radiusSm
|
||||
color: closeArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
QbzIcon {
|
||||
name: "x"
|
||||
width: 17
|
||||
height: 17
|
||||
anchors.centerIn: parent
|
||||
tintName: closeArea.containsMouse ? "textPrimary" : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: closeArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Live status block (read-only; selectable/copyable like the
|
||||
// Slint TextEdit).
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 140
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
|
||||
Flickable {
|
||||
id: statusFlick
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
contentWidth: width
|
||||
contentHeight: statusText.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
clip: true
|
||||
TextEdit {
|
||||
id: statusText
|
||||
width: statusFlick.width
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: TextEdit.Wrap
|
||||
font.pixelSize: 12
|
||||
color: theme.textPrimary
|
||||
text: QbzQConnect.diagStatus === ""
|
||||
? "Not connected."
|
||||
: QbzQConnect.diagStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Event-log header + clear.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 26
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Event log (newest first)"
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: clearLabel.implicitWidth + 22
|
||||
height: 26
|
||||
radius: theme.radiusSm
|
||||
color: clearArea.containsMouse ? theme.surfaceCard : "transparent"
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Text {
|
||||
id: clearLabel
|
||||
anchors.centerIn: parent
|
||||
text: "Clear"
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
}
|
||||
MouseArea {
|
||||
id: clearArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzQConnect.diagClear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rolling event log (newest first) — read-only so the lines
|
||||
// are selectable + copyable, and it scrolls. Transparent
|
||||
// body, exactly like the Slint's unstyled Rectangle.
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: parent.height - 28 - 140 - 26 - 3 * 14
|
||||
color: "transparent"
|
||||
clip: true
|
||||
|
||||
Flickable {
|
||||
id: logFlick
|
||||
anchors.fill: parent
|
||||
contentWidth: width
|
||||
contentHeight: logText.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
clip: true
|
||||
TextEdit {
|
||||
id: logText
|
||||
width: logFlick.width
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: TextEdit.Wrap
|
||||
font.pixelSize: 11
|
||||
color: theme.textSecondary
|
||||
text: QbzQConnect.diagLogText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// Qobuz Connect device flyout — the QML port of the PopupWindow both Slint
|
||||
// bars mount (`shell/PlayerBar.slint:412-642` + `shell/PlayerBarSmall.slint:
|
||||
// 374-596`). ONE shared component is the contract §8 sanctioned collapse of
|
||||
// the two Slint flyouts (their only delta was 1px of popup y); the bar-side
|
||||
// asymmetry that survives is the BUTTON's tooltip, which lives in the bars,
|
||||
// not here.
|
||||
//
|
||||
// STRUCTURE + BEHAVIOR are 1:1 with the Slint; geometry follows this port's
|
||||
// own popup conventions (controls/QbzContextMenu.qml): a Popup parented to
|
||||
// the Overlay, opened below-right of the trigger and clamped into the window
|
||||
// with an 8px margin — the same placement ViewModeMenu / AudioSettingsMenu
|
||||
// use from the same bars. Slint's fixed 280x244 panel, 14px padding and 12px
|
||||
// block spacing are kept.
|
||||
//
|
||||
// STATE: all session data is push-driven off the QbzQConnect singleton
|
||||
// (src/qconnect_bridge.rs) — `devicesJson` is parsed here exactly like
|
||||
// CastPicker.qml parses QbzCast.devicesJson, guarded so a malformed/absent
|
||||
// document degrades to the empty state instead of throwing. The 8s
|
||||
// "Looking for devices…" discovery window is a QML-side flag + Timer
|
||||
// (`qcConnecting`), 1:1 with the Slint `qc-connecting` UI timer — there is
|
||||
// deliberately NO Rust busy flag (contract §8 state-mapping table).
|
||||
//
|
||||
// List states, mutually exclusive (Slint :504-601):
|
||||
// discovering qcConnecting && devices empty -> spinner + caption
|
||||
// empty !qcConnecting && devices empty -> "No devices found" + hint
|
||||
// devices devices non-empty -> 34px rows, tap to set active
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import com.blitzfc.qbz
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
|
||||
Popup {
|
||||
id: root
|
||||
|
||||
parent: Overlay.overlay
|
||||
width: 280
|
||||
height: 244
|
||||
padding: 14
|
||||
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// True while a Connect click is discovering renderers (PlayerBar.slint:
|
||||
// 164). The spinner is gated on this + the 8s timer below; UI-only.
|
||||
property bool qcConnecting: false
|
||||
|
||||
// The parsed device document (QbzQConnect.devicesJson). Rows:
|
||||
// { renderer_id, name, is_local, is_active, icon } — the exact shape the
|
||||
// bridge header documents.
|
||||
readonly property var devices: {
|
||||
try {
|
||||
var d = JSON.parse(QbzQConnect.devicesJson || "[]")
|
||||
return Array.isArray(d) ? d : []
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Discovery timer — bounds the spinner so the "no devices" empty state
|
||||
// shows if none appear within ~8s (Slint :486-493). Started on a Connect
|
||||
// click; stopped on disconnect. Single-shot, so triggering self-stops.
|
||||
Timer {
|
||||
id: discoverTimer
|
||||
interval: 8000
|
||||
onTriggered: root.qcConnecting = false
|
||||
}
|
||||
|
||||
// Placement — byte-for-byte the QbzContextMenu convention: below the
|
||||
// trigger, right edges aligned, clamped 8px into the window. From the
|
||||
// bottom bar the y clamp is what lifts the panel into view.
|
||||
function _place(gx, gy, win) {
|
||||
if (win) {
|
||||
x = Math.max(8, Math.min(gx, win.width - width - 8))
|
||||
y = Math.max(8, Math.min(gy, win.height - height - 8))
|
||||
} else {
|
||||
x = gx
|
||||
y = gy
|
||||
}
|
||||
open()
|
||||
}
|
||||
|
||||
function openBelowRight(sourceItem) {
|
||||
var g = sourceItem.mapToItem(null, sourceItem.width - width, sourceItem.height + 4)
|
||||
_place(g.x, g.y, sourceItem.Window.window)
|
||||
}
|
||||
|
||||
// The Slint chrome: surface-main, Radius.sm, 1px border-muted.
|
||||
background: Rectangle {
|
||||
color: theme.surfaceMain
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: theme.borderMuted
|
||||
}
|
||||
|
||||
contentItem: Item {
|
||||
Column {
|
||||
id: topCol
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: 12
|
||||
|
||||
// Header.
|
||||
Row {
|
||||
width: parent.width
|
||||
height: 16
|
||||
spacing: 8
|
||||
QbzIcon {
|
||||
name: "monitor-speaker"
|
||||
width: 16
|
||||
height: 16
|
||||
tintName: "textPrimary"
|
||||
}
|
||||
Text {
|
||||
text: QbzSession.tr("Qobuz Connect", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 14
|
||||
font.weight: theme.weightSemibold
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
// Status divider.
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
|
||||
// Status row.
|
||||
Row {
|
||||
width: parent.width
|
||||
height: 12
|
||||
spacing: 8
|
||||
Rectangle {
|
||||
width: 8
|
||||
height: 8
|
||||
radius: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: QbzQConnect.qconnectConnected ? "#22c55e" : theme.textMuted
|
||||
}
|
||||
Text {
|
||||
text: QbzQConnect.qconnectConnected
|
||||
? QbzSession.tr("Connected", QbzSession.trRev)
|
||||
: QbzSession.tr("Disconnected", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
// "Playing on" line — only while a peer renderer owns playback
|
||||
// (controller mode). Invisible children are skipped by the Column.
|
||||
Text {
|
||||
visible: QbzPlayer.npIsRemote && QbzPlayer.npCastTarget !== ""
|
||||
width: parent.width
|
||||
text: QbzSession.tr("Playing on: {}", QbzSession.trRev)
|
||||
.replace("{}", QbzPlayer.npCastTarget)
|
||||
color: theme.accent
|
||||
font.pixelSize: 11
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
// Connect / Disconnect toggle (declared before the list box so the
|
||||
// box can anchor against it; the list takes the stretch between).
|
||||
Rectangle {
|
||||
id: connectBtn
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: 34
|
||||
radius: theme.radiusSm
|
||||
color: connectArea.pressed ? theme.accentPressed
|
||||
: connectArea.containsMouse ? theme.accentHover : theme.accent
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: QbzQConnect.qconnectConnected
|
||||
? QbzSession.tr("Disconnect", QbzSession.trRev)
|
||||
: QbzSession.tr("Connect", QbzSession.trRev)
|
||||
// On an accent fill: the theme's on-accent selector, the same
|
||||
// correction CastPicker's tabs document (Slint's raw
|
||||
// accent-text fails contrast on light themes).
|
||||
color: theme.accentGlyphColor
|
||||
font.pixelSize: 13
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
MouseArea {
|
||||
id: connectArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (QbzQConnect.qconnectConnected) {
|
||||
// Connected -> this click DISCONNECTS -> close the
|
||||
// flyout (Slint :612-618).
|
||||
QbzQConnect.connectToggle()
|
||||
root.qcConnecting = false
|
||||
discoverTimer.stop()
|
||||
root.close()
|
||||
} else {
|
||||
// Disconnected -> CONNECT -> keep the flyout open and
|
||||
// spin while renderers are discovered (Slint :619-626).
|
||||
QbzQConnect.connectToggle()
|
||||
root.qcConnecting = true
|
||||
discoverTimer.restart()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Device list / empty state — the Slint's vertical-stretch block
|
||||
// (:495-602): surface-elevated, Radius.sm, clipped.
|
||||
Rectangle {
|
||||
id: listBox
|
||||
anchors.top: topCol.bottom
|
||||
anchors.topMargin: 12
|
||||
anchors.bottom: connectBtn.top
|
||||
anchors.bottomMargin: 12
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
|
||||
// Discovering — Connect was clicked and no renderers have arrived
|
||||
// yet. Hides once devices appear or the timer expires.
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 6
|
||||
visible: root.qcConnecting && root.devices.length === 0
|
||||
QbzSpinner {
|
||||
size: 22
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
Text {
|
||||
text: QbzSession.tr("Looking for devices…", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state (no session renderers yet).
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 24
|
||||
spacing: 4
|
||||
visible: !root.qcConnecting && root.devices.length === 0
|
||||
Text {
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: QbzSession.tr("No devices found", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: QbzSession.tr("Make sure your renderer is on the same network", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 10
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
// Device picker — plain rows (ADR-008: no pills). Tap a row to
|
||||
// make it the active renderer; the local row is "Play here".
|
||||
Flickable {
|
||||
id: listFlick
|
||||
anchors.fill: parent
|
||||
anchors.margins: 4
|
||||
visible: root.devices.length > 0
|
||||
contentWidth: width
|
||||
contentHeight: devRows.height
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
clip: true
|
||||
|
||||
Column {
|
||||
id: devRows
|
||||
width: listFlick.width
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: root.devices
|
||||
|
||||
delegate: Rectangle {
|
||||
id: deviceRow
|
||||
required property var modelData
|
||||
// The Slint lights `d.is-active` from the row
|
||||
// (PlayerBar.slint:576,581,583,595); the bridge's
|
||||
// activeRendererId is the fallback when a row
|
||||
// lacks the flag.
|
||||
readonly property bool rowActive: modelData.is_active === true
|
||||
|| (modelData.is_active === undefined
|
||||
&& modelData.renderer_id === QbzQConnect.activeRendererId)
|
||||
|
||||
width: devRows.width
|
||||
height: 34
|
||||
radius: theme.radiusSm
|
||||
color: rowArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
spacing: 8
|
||||
QbzIcon {
|
||||
// Device-type glyph, mirroring Qobuz
|
||||
// Connect. The sink's keys are mobile /
|
||||
// web / computer / speaker (its own
|
||||
// default arm is "speaker"), so the
|
||||
// fallback here is speaker too.
|
||||
name: deviceRow.modelData.icon === "mobile" ? "smartphone"
|
||||
: deviceRow.modelData.icon === "web" ? "globe"
|
||||
: deviceRow.modelData.icon === "computer" ? "monitor"
|
||||
: "speaker"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: deviceRow.rowActive ? "accent" : "secondary"
|
||||
}
|
||||
Text {
|
||||
width: parent.width - 14 - 8 - 16
|
||||
text: deviceRow.modelData.is_local === true
|
||||
? QbzSession.tr("Play here", QbzSession.trRev)
|
||||
: (deviceRow.modelData.name || "")
|
||||
color: deviceRow.rowActive ? theme.textPrimary : theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
font.weight: deviceRow.rowActive ? theme.weightSemibold : Font.Normal
|
||||
elide: Text.ElideRight
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
// Active indicator dot (no pill).
|
||||
Rectangle {
|
||||
width: 8
|
||||
height: 8
|
||||
radius: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: deviceRow.rowActive ? theme.accent : "transparent"
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: rowArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
QbzQConnect.setActive(deviceRow.modelData.renderer_id)
|
||||
root.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@
|
||||
// could not be trusted: `black/` and `warning/` were missing mp3 + translate,
|
||||
// `secondary/` and `accent/` were missing mp3 (and `black/cd.svg` was a
|
||||
// #cccccc glyph — a light-grey disc in the DARK-glyph directory). All six
|
||||
// neutral/chromatic dirs are now 111 files, the same set as primary/ and as
|
||||
// neutral/chromatic dirs are now 115 files, the same set as primary/ and as
|
||||
// MASTERS; only `favorite/` (57), `amber/` (6) and the two one-glyph brand
|
||||
// dirs `green/` / `orange/` stay deliberately partial, and none of them is
|
||||
// reachable from the polarity mapping. A new icon goes into
|
||||
|
||||
@@ -28,15 +28,27 @@
|
||||
//! ONE file for the same reason; this is the trimmed port of it. The Qt
|
||||
//! boundary (properties + invokables) IS split out, into `cast_bridge.rs`.
|
||||
//!
|
||||
//! QConnect coexistence (contract §11.4, 1:1 with cast_service.rs:280-294 /
|
||||
//! :405-437 / :1140-1161): cast connect halts local playback, then SUSPENDS a
|
||||
//! live QConnect session (best-effort — casting never blocks on it); cast
|
||||
//! disconnect restores it. The golden bar badge is republished around the
|
||||
//! suspend/restore because the facade deliberately leaves badge flips to its
|
||||
//! callers (the toggle / startup auto-connect / offline watcher pattern).
|
||||
//!
|
||||
//! DELIBERATE CUTS vs the Slint service, each named:
|
||||
//! - no offline-cache tier: the Qt port never brings up `OfflineCacheState`
|
||||
//! (`settings_qt/offline.rs` says so), so the resolver is called with
|
||||
//! `None` — cache -> network still works, a download-only track does not;
|
||||
//! - no QConnect coexistence: there is no QConnect service in the Qt port;
|
||||
//! - no Plex casting: the Slint has the same TODO (needs the Plex bytes
|
||||
//! resolver);
|
||||
//! - no lyrics remote anchor: `lyrics_qt::position_ms` reads the local
|
||||
//! player directly (see GLUE in the report).
|
||||
//! - no CAST-side lyrics anchor feed: the Slint cast poll publishes its
|
||||
//! position into the lyrics remote anchor (cast_service.rs:1086-1097) so
|
||||
//! lyrics auto-follow while casting; here `lyrics_qt::position_ms` reads
|
||||
//! the local player while casting (the QConnect anchor, §11.1, gates on
|
||||
//! `now_playing::is_remote()`, which is false during a cast — see GLUE in
|
||||
//! the report). The cast-disconnect `clear_remote_anchor`
|
||||
//! (cast_service.rs:439-440) is likewise subsumed: the QConnect suspend
|
||||
//! already cleared the anchor through `now_playing::set_remote(false)`.
|
||||
//! ADDITION (asked for): a renderer that goes silent mid-session is torn down
|
||||
//! after `LOST_POLL_MAX` consecutive failed reads instead of leaving the UI
|
||||
//! claiming a live connection forever.
|
||||
@@ -166,6 +178,9 @@ struct CastInner {
|
||||
cast_premature_stop_polls: u32,
|
||||
// Consecutive failed position reads (device-disappeared detection).
|
||||
lost_polls: u32,
|
||||
// QConnect coexistence (§11.4): whether QConnect was on before casting,
|
||||
// so `disconnect` restores exactly the sessions `connect` suspended.
|
||||
qconnect_was_on_before_cast: bool,
|
||||
// Position-poll task; aborted on disconnect.
|
||||
poll_task: Option<tokio::task::JoinHandle<()>>,
|
||||
// Device-refresh task (2 s loop while the picker is open).
|
||||
@@ -272,8 +287,9 @@ impl CastService {
|
||||
|
||||
// ---- Connect / disconnect ----------------------------------------------
|
||||
|
||||
/// Connect to a device: halt local playback, then re-cast the current
|
||||
/// track at its position if one was playing (`castStore.connectToDevice`).
|
||||
/// Connect to a device: halt local playback, suspend QConnect if it was
|
||||
/// on (§11.4), then re-cast the current track at its position if one was
|
||||
/// playing (`castStore.connectToDevice`).
|
||||
pub(crate) async fn connect(
|
||||
self: &Arc<Self>,
|
||||
device_id: String,
|
||||
@@ -292,6 +308,11 @@ impl CastService {
|
||||
// protected path's public seam only — nothing about it changes.
|
||||
let _ = self.runtime.core().stop();
|
||||
|
||||
// Suspend QConnect if it was on (§11.4 — best-effort; NEVER blocks
|
||||
// casting). Same ordering as the reference (cast_service.rs:293-294):
|
||||
// after the local halt, before the renderer connect.
|
||||
self.suspend_qconnect_if_on().await;
|
||||
|
||||
// Connect to the renderer.
|
||||
let device_ip = match proto {
|
||||
CastProtocol::Chromecast => self.connect_chromecast(&device_id).await?,
|
||||
@@ -400,12 +421,13 @@ impl CastService {
|
||||
Ok(ip)
|
||||
}
|
||||
|
||||
/// Disconnect: stop the renderer, drop the connection, reset state.
|
||||
/// Disconnect: stop the renderer, drop the connection, restore the
|
||||
/// QConnect session connect() suspended (§11.4), reset state.
|
||||
pub(crate) async fn disconnect(&self) {
|
||||
// Stop the renderer first (disconnect alone leaves it playing).
|
||||
let _ = self.stop_renderer().await;
|
||||
|
||||
let poll = {
|
||||
let (poll, was_on) = {
|
||||
let mut inner = self.inner.lock().await;
|
||||
if let Some(h) = inner.chromecast.take() {
|
||||
let _ = h.disconnect();
|
||||
@@ -427,11 +449,17 @@ impl CastService {
|
||||
inner.is_playing = false;
|
||||
inner.track_end_detected = false;
|
||||
inner.lost_polls = 0;
|
||||
inner.poll_task.take()
|
||||
(inner.poll_task.take(), inner.qconnect_was_on_before_cast)
|
||||
};
|
||||
if let Some(task) = poll {
|
||||
task.abort();
|
||||
}
|
||||
// Restore the QConnect session connect() suspended (best-effort),
|
||||
// then reset the latch (cast_service.rs:435-438).
|
||||
if was_on {
|
||||
self.restore_qconnect().await;
|
||||
self.inner.lock().await.qconnect_was_on_before_cast = false;
|
||||
}
|
||||
// Clear the per-connection disclosure + cap row.
|
||||
cast_bridge::ui(|mut b| {
|
||||
b.as_mut().set_quality_limit_cause(0);
|
||||
@@ -446,6 +474,44 @@ impl CastService {
|
||||
self.push_connection_state().await;
|
||||
}
|
||||
|
||||
// ---- QConnect coexistence (§11.4 — cast_service.rs:1140-1161) -----------
|
||||
|
||||
/// Suspend QConnect while casting (mutual exclusion). Best-effort: a
|
||||
/// failure logs and casting proceeds. The latch is recorded ONLY when a
|
||||
/// session was actually live, so `disconnect` restores exactly what this
|
||||
/// suspended.
|
||||
async fn suspend_qconnect_if_on(&self) {
|
||||
let Some(qc) = crate::qconnect_qt::service() else {
|
||||
return;
|
||||
};
|
||||
if !qc.is_running().await {
|
||||
return;
|
||||
}
|
||||
self.inner.lock().await.qconnect_was_on_before_cast = true;
|
||||
if let Err(e) = qc.disconnect().await {
|
||||
log::warn!("[qbz-qt][Cast] QConnect suspend failed (continuing): {e}");
|
||||
}
|
||||
// The facade deliberately does NOT flip the bar badge itself (the
|
||||
// toggle / startup auto-connect / offline force-disconnect paths each
|
||||
// publish their own — the qconnect_bridge.rs connectToggle tail); a
|
||||
// suspend must not leave the golden button lit while the session is
|
||||
// down. The facade's disconnect always tears the runtime down, so the
|
||||
// badge goes dark even when the call above logged an error.
|
||||
crate::qconnect_qt::publish::connected(false);
|
||||
}
|
||||
|
||||
/// Bring the suspended session back after casting (best-effort). The
|
||||
/// badge only re-lights when the session is actually live again.
|
||||
async fn restore_qconnect(&self) {
|
||||
let Some(qc) = crate::qconnect_qt::service() else {
|
||||
return;
|
||||
};
|
||||
match qc.connect().await {
|
||||
Ok(()) => crate::qconnect_qt::publish::connected(true),
|
||||
Err(e) => log::warn!("[qbz-qt][Cast] QConnect restore failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Casting a track ----------------------------------------------------
|
||||
|
||||
/// Resolve a track's bytes + MIME, register them with the shared media
|
||||
|
||||
@@ -144,7 +144,7 @@ pub struct QbzIconTintRust {
|
||||
|
||||
/// Every icon the app can ask for, as the `primary/` bake's source text.
|
||||
///
|
||||
/// This IS the shipped `qml/assets/icons/primary/` directory (111 files),
|
||||
/// This IS the shipped `qml/assets/icons/primary/` directory (115 files),
|
||||
/// compiled in. Two consequences worth knowing:
|
||||
/// - every runtime tint is COMPLETE, so the partial-directory trap the qrc
|
||||
/// bakes have (`favorite/` = 57 files, `amber/` = 6, plus the two
|
||||
@@ -188,6 +188,7 @@ const MASTERS: &[(&str, &str)] = &[
|
||||
("folder-open", include_str!("../qml/assets/icons/primary/folder-open.svg")),
|
||||
("folder-plus", include_str!("../qml/assets/icons/primary/folder-plus.svg")),
|
||||
("folder", include_str!("../qml/assets/icons/primary/folder.svg")),
|
||||
("globe", include_str!("../qml/assets/icons/primary/globe.svg")),
|
||||
("grip-vertical", include_str!("../qml/assets/icons/primary/grip-vertical.svg")),
|
||||
("hard-drive", include_str!("../qml/assets/icons/primary/hard-drive.svg")),
|
||||
("heart-filled", include_str!("../qml/assets/icons/primary/heart-filled.svg")),
|
||||
@@ -219,6 +220,7 @@ const MASTERS: &[(&str, &str)] = &[
|
||||
("mic-vocal", include_str!("../qml/assets/icons/primary/mic-vocal.svg")),
|
||||
("minimize-2", include_str!("../qml/assets/icons/primary/minimize-2.svg")),
|
||||
("minus", include_str!("../qml/assets/icons/primary/minus.svg")),
|
||||
("monitor", include_str!("../qml/assets/icons/primary/monitor.svg")),
|
||||
("monitor-speaker", include_str!("../qml/assets/icons/primary/monitor-speaker.svg")),
|
||||
("moon", include_str!("../qml/assets/icons/primary/moon.svg")),
|
||||
("mp3", include_str!("../qml/assets/icons/primary/mp3.svg")),
|
||||
@@ -248,6 +250,8 @@ const MASTERS: &[(&str, &str)] = &[
|
||||
("skip-back", include_str!("../qml/assets/icons/primary/skip-back.svg")),
|
||||
("skip-forward", include_str!("../qml/assets/icons/primary/skip-forward.svg")),
|
||||
("sliders-horizontal", include_str!("../qml/assets/icons/primary/sliders-horizontal.svg")),
|
||||
("smartphone", include_str!("../qml/assets/icons/primary/smartphone.svg")),
|
||||
("speaker", include_str!("../qml/assets/icons/primary/speaker.svg")),
|
||||
("square-check-big", include_str!("../qml/assets/icons/primary/square-check-big.svg")),
|
||||
("star", include_str!("../qml/assets/icons/primary/star.svg")),
|
||||
("sun-moon", include_str!("../qml/assets/icons/primary/sun-moon.svg")),
|
||||
@@ -275,8 +279,8 @@ const MASTERS: &[(&str, &str)] = &[
|
||||
("x", include_str!("../qml/assets/icons/primary/x.svg")),
|
||||
];
|
||||
|
||||
/// The only paint literal in a `primary/` master (verified over all 111 files:
|
||||
/// 121 occurrences, no `#fff` short form). The uppercase form is accepted too
|
||||
/// The only paint literal in a `primary/` master (verified over all 115 files:
|
||||
/// 125 occurrences, no `#fff` short form). The uppercase form is accepted too
|
||||
/// so a hand-added master cannot silently skip the substitution.
|
||||
const MASTER_HEX_LOWER: &str = "#ffffff";
|
||||
const MASTER_HEX_UPPER: &str = "#FFFFFF";
|
||||
|
||||
@@ -596,12 +596,22 @@ pub fn on_track_changed(meta: ScrobbleMeta) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The whole integrations reaction to a track-change edge, in one call:
|
||||
/// The whole integrations reaction to a LOCAL track-change edge, in one call:
|
||||
/// scrobble now-playing + arm the delayed scrobble, and refresh the Discord
|
||||
/// presence. Both halves early-return when the user has not opted in, so this
|
||||
/// is free for everyone else (the queue state is only read when at least one
|
||||
/// integration is live).
|
||||
///
|
||||
/// §11.2 SPLIT (Slint playback.rs:2266-2278): the peer-active guard covers
|
||||
/// the SCROBBLE half only — never scrobble a peer's audio. The Discord half
|
||||
/// is deliberately UNGUARDED: in Slint controller mode Discord keeps
|
||||
/// following the peer's tracks while scrobbles are suppressed (the push
|
||||
/// inside `refresh_now_playing_meta`, playback.rs:2236-2239, which the peer
|
||||
/// track edge also reaches, :5137-5141). The poll loop's PEER edge therefore
|
||||
/// calls [`discord_push`] directly and never this entry. The ephemeral-mode
|
||||
/// product law (scrobble MAY happen) is unaffected — this guard is about
|
||||
/// REMOTE ownership, not ephemeral.
|
||||
///
|
||||
/// GLUE: call from the playback poll's DE-DUPED track-change edge
|
||||
/// (`track_id != last_track_id`), never from `refresh_now_playing` — that runs
|
||||
/// on every play/queue republish and would re-arm the scrobble timer.
|
||||
@@ -614,11 +624,24 @@ pub fn on_track_change_edge(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
let rt = runtime.clone();
|
||||
crate::spawn(async move {
|
||||
if scrobblers_live {
|
||||
let state = rt.core().get_queue_state().await;
|
||||
if let Some(track) = state.current_track {
|
||||
on_track_changed(meta_from_queue_track(&track));
|
||||
// The guard (playback.rs:2267-2271): a remote QConnect renderer
|
||||
// owns playback — skip the scrobble half ONLY. The spawn's other
|
||||
// half (Discord) must still run, so this is a skip, not the
|
||||
// reference's early `return` (its spawn holds no Discord half).
|
||||
// Fires in the topology-vs-snapshot window, where `is_peer_active`
|
||||
// is already true but the poll loop still runs the LOCAL path.
|
||||
let peer_active = match crate::qconnect_qt::service() {
|
||||
Some(svc) => svc.is_peer_active().await,
|
||||
None => false,
|
||||
};
|
||||
if !peer_active {
|
||||
let state = rt.core().get_queue_state().await;
|
||||
if let Some(track) = state.current_track {
|
||||
on_track_changed(meta_from_queue_track(&track));
|
||||
}
|
||||
}
|
||||
}
|
||||
// UNGUARDED, 1:1 with the reference — Discord follows the peer.
|
||||
discord_push(&rt);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -474,6 +474,10 @@ async fn play_rows(runtime: &Runtime, tracks: Vec<LocalTrack>) {
|
||||
|
||||
/// "Play next" / "Play later" / "Add to queue" over a track SUBSET — the same
|
||||
/// core helpers `local_playback::enqueue` uses, just without the id round-trip.
|
||||
///
|
||||
/// QConnect EXEMPT (contract §6.3): no routed arm / sync-on-add tail — a
|
||||
/// local-only batch is never Qobuz-castable, so the predicate is always false
|
||||
/// (Slint local_album_actions.rs:486-494 is likewise unhooked).
|
||||
async fn enqueue_rows(runtime: &Runtime, tracks: Vec<LocalTrack>, mode: &str) {
|
||||
let queue: Vec<QueueTrack> = tracks.iter().map(local_queue_track).collect();
|
||||
if queue.is_empty() {
|
||||
|
||||
@@ -251,6 +251,10 @@ async fn apply(rows: Vec<LocalTrack>, action: &str) -> bool {
|
||||
/// (`local_playback::enqueue`): "play-next" inserts at the cursor REVERSED so
|
||||
/// a multi-row insert keeps its order, "play-later" appends to the manual
|
||||
/// block's tail, anything else appends.
|
||||
///
|
||||
/// QConnect EXEMPT (contract §6.3): no routed arm / sync-on-add tail — a
|
||||
/// local-only batch is never Qobuz-castable, so the predicate is always false
|
||||
/// (Slint local_bulk.rs:261-269 is likewise unhooked).
|
||||
async fn enqueue_rows(rows: Vec<LocalTrack>, mode: &str) {
|
||||
let runtime = crate::app();
|
||||
let queue: Vec<QueueTrack> = rows.iter().map(local_queue_track).collect();
|
||||
|
||||
@@ -436,6 +436,12 @@ pub async fn enqueue(runtime: &Runtime, kind: String, id: String, mode: String)
|
||||
// Same core helpers the Qobuz rows use: "next" inserts at the cursor
|
||||
// (reversed so a multi-track insert keeps its order), "later" appends to
|
||||
// the block tail, anything else appends.
|
||||
//
|
||||
// QConnect EXEMPT (contract §6.3): no routed arm and no sync-on-add tail
|
||||
// here — this is a LOCAL-only path, so `batch_all_qconnect_castable` is
|
||||
// always false and the push would never fire; hooking the arm would only
|
||||
// risk the refusal toast on every click (Slint local_playback.rs:437-450
|
||||
// is likewise unhooked).
|
||||
match mode.as_str() {
|
||||
"next" => {
|
||||
for t in queue.into_iter().rev() {
|
||||
|
||||
@@ -23,21 +23,33 @@
|
||||
//!
|
||||
//! # Position source
|
||||
//!
|
||||
//! [`position_ms`] reads `SharedState::current_position_ms()` — the read-only
|
||||
//! ms getter on `qbz-player` (pure derivation from the existing playback
|
||||
//! anchors; the audio path is untouched). The 1 Hz `npElapsedSecs` poll is
|
||||
//! NOT used for lyrics: it is second-granular and would resurrect the 1 Hz
|
||||
//! highlight this port is replacing.
|
||||
//! Locally, [`position_ms`] reads `SharedState::current_position_ms()` — the
|
||||
//! read-only ms getter on `qbz-player` (pure derivation from the existing
|
||||
//! playback anchors; the audio path is untouched). The 1 Hz `npElapsedSecs`
|
||||
//! poll is NOT used for lyrics: it is second-granular and would resurrect the
|
||||
//! 1 Hz highlight this port is replacing.
|
||||
//!
|
||||
//! While a peer Qobuz Connect renderer owns playback (Q7, contract §11.1 —
|
||||
//! port of `lyrics_sync.rs`'s REMOTE_* store), the sync invokables read the
|
||||
//! REMOTE anchor instead: the playback poll's peer branch publishes the raw
|
||||
//! renderer triple (position_ms / updated_at_ms / playing — MS end-to-end,
|
||||
//! the facade's `RemoteNowPlaying` units) via [`publish_remote_anchor`] on
|
||||
//! every tick, and [`position_ms`] extrapolates by `now_ms - updated_at_ms`
|
||||
//! while playing, exactly like the bar's peer position. The remote gate is
|
||||
//! the now-playing model's `is_remote` flag (`now_playing::is_remote()`);
|
||||
//! the anchor itself clears from `now_playing::set_remote(false)` — the
|
||||
//! single choke point every remote-end path funnels through (the qconnect
|
||||
//! 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)
|
||||
//! - QConnect peer-position anchor (Q7): the Qt port has no controller mode.
|
||||
//! - 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.
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicI32, AtomicI64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use cxx_qt::CxxQtThread;
|
||||
@@ -118,11 +130,14 @@ pub mod qbz_lyrics_bridge {
|
||||
fn boot(self: Pin<&mut QbzLyrics>);
|
||||
|
||||
// --- Sync engine (pulled by LyricsSyncEngine.qml) -----------------
|
||||
/// Millisecond playback position. Read-only derivation from the
|
||||
/// player's existing anchors — never a poll of the audio path.
|
||||
/// Millisecond playback position. Local: read-only derivation from
|
||||
/// the player's existing anchors — never a poll of the audio path.
|
||||
/// While a peer QConnect renderer owns playback (§11.1): the remote
|
||||
/// anchor, extrapolated by `now - updated_at_ms` when playing.
|
||||
#[qinvokable]
|
||||
fn position_ms(self: &QbzLyrics) -> f64;
|
||||
/// Effective playing flag (the engine's tick gate).
|
||||
/// Effective playing flag (the engine's tick gate). Remote while a
|
||||
/// peer owns playback, like [`position_ms`].
|
||||
#[qinvokable]
|
||||
fn playing(self: &QbzLyrics) -> bool;
|
||||
/// Active line index at `now_ms`, or -1 (binary search,
|
||||
@@ -218,6 +233,52 @@ pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzLyrics>) + Send + 'static) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QConnect remote anchor (contract §11.1 — port of lyrics_sync.rs's REMOTE_*)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The peer renderer's RAW playback anchor, published by the playback poll's
|
||||
/// peer branch on every tick while a peer owns playback (Slint
|
||||
/// playback.rs:5142-5149). MS end-to-end — the facade's `RemoteNowPlaying`
|
||||
/// units — until `position_ms()` extrapolates. Plain atomics, like the
|
||||
/// reference: both sides touch them at low rates and a torn read across
|
||||
/// fields self-corrects on the next tick. There is deliberately NO
|
||||
/// lyrics-side ACTIVE flag (the Slint's `REMOTE_ACTIVE`): the getters gate
|
||||
/// on `now_playing::is_remote()` instead, so the anchor's owner and the
|
||||
/// gate's owner are the same model flag.
|
||||
static REMOTE_POSITION_MS: AtomicU64 = AtomicU64::new(0);
|
||||
static REMOTE_UPDATED_AT_MS: AtomicU64 = AtomicU64::new(0);
|
||||
static REMOTE_PLAYING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Wall-clock now in ms — the anchor extrapolation clock (the Slint
|
||||
/// `now_ms`).
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Publish the RAW peer-renderer anchor (NOT the already-extrapolated
|
||||
/// position — the getter extrapolates at read time, like the reference's
|
||||
/// engine tick). Called from the playback poll's peer branch on every tick
|
||||
/// while a peer owns playback (`lyrics_sync::publish_remote_anchor`).
|
||||
pub fn publish_remote_anchor(position_ms: u64, updated_at_ms: u64, playing: bool) {
|
||||
REMOTE_POSITION_MS.store(position_ms, Ordering::Relaxed);
|
||||
REMOTE_UPDATED_AT_MS.store(updated_at_ms, Ordering::Relaxed);
|
||||
REMOTE_PLAYING.store(playing, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Drop the stored anchor. Called from `now_playing::set_remote(false)` —
|
||||
/// the single remote-mode-ENDED choke point (see the note there). The
|
||||
/// getters gate on that same model flag, so this is hygiene against a stale
|
||||
/// first read on remote re-entry, not the gate itself.
|
||||
pub fn clear_remote_anchor() {
|
||||
REMOTE_POSITION_MS.store(0, Ordering::Relaxed);
|
||||
REMOTE_UPDATED_AT_MS.store(0, Ordering::Relaxed);
|
||||
REMOTE_PLAYING.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
impl qbz_lyrics_bridge::QbzLyrics {
|
||||
pub fn boot(mut self: Pin<&mut Self>) {
|
||||
let thread = self.qt_thread();
|
||||
@@ -233,6 +294,18 @@ impl qbz_lyrics_bridge::QbzLyrics {
|
||||
// ---- sync engine ---------------------------------------------------
|
||||
|
||||
pub fn position_ms(&self) -> f64 {
|
||||
// QConnect controller mode (§11.1): lyrics follow the PEER.
|
||||
// Extrapolate between the poll's anchor publishes exactly like the
|
||||
// reference's resolver (lyrics_sync.rs:176-183 — no duration clamp
|
||||
// there either).
|
||||
if crate::now_playing::is_remote() {
|
||||
let position_ms = REMOTE_POSITION_MS.load(Ordering::Relaxed);
|
||||
let updated_at_ms = REMOTE_UPDATED_AT_MS.load(Ordering::Relaxed);
|
||||
if REMOTE_PLAYING.load(Ordering::Relaxed) && updated_at_ms > 0 {
|
||||
return position_ms.saturating_add(now_ms().saturating_sub(updated_at_ms)) as f64;
|
||||
}
|
||||
return position_ms as f64;
|
||||
}
|
||||
match runtime_handle() {
|
||||
Some(runtime) => runtime.core().player().state.current_position_ms() as f64,
|
||||
None => 0.0,
|
||||
@@ -240,6 +313,9 @@ impl qbz_lyrics_bridge::QbzLyrics {
|
||||
}
|
||||
|
||||
pub fn playing(&self) -> bool {
|
||||
if crate::now_playing::is_remote() {
|
||||
return REMOTE_PLAYING.load(Ordering::Relaxed);
|
||||
}
|
||||
match runtime_handle() {
|
||||
Some(runtime) => runtime.core().player().state.is_playing(),
|
||||
None => false,
|
||||
|
||||
@@ -54,6 +54,10 @@ mod playlist_edit_bridge;
|
||||
// manager, opened from two shell surfaces that outlive each other, and its
|
||||
// modal must survive the one that opened it (05 §5.8).
|
||||
mod playlist_import_bridge;
|
||||
// Qobuz Connect (2026-08-01 contract §8, block B4-Rust): the QbzQConnect
|
||||
// singleton — the QML surface of the facade/sink controllers below
|
||||
// (qconnect_qt.rs / qconnect_event_sink_qt.rs).
|
||||
mod qconnect_bridge;
|
||||
mod artwork_qt;
|
||||
mod atmosphere_qt;
|
||||
mod bridge;
|
||||
@@ -155,6 +159,17 @@ mod sleep_timer_qt;
|
||||
mod theme_qt;
|
||||
mod integrations_qt;
|
||||
mod viz_qt;
|
||||
// Qobuz Connect port (2026-08-01 contract), blocks B1/B2: transport config +
|
||||
// credential discovery + device identity, and the renderer engine over
|
||||
// `runtime.core()`. Plain modules — they declare no #[cxx_qt::bridge], so
|
||||
// they must NOT appear in build.rs's rust_files. Wired by the B3 facade.
|
||||
mod qconnect_engine_qt;
|
||||
mod qconnect_transport_qt;
|
||||
// QConnect port block B3: the facade (service singleton + controller routing)
|
||||
// and the inbound event sink. Plain modules, same convention as B1/B2. The
|
||||
// §11.5 wiring (init_service + spawns) lives in `on_session_entered` below.
|
||||
mod qconnect_event_sink_qt;
|
||||
mod qconnect_qt;
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
@@ -377,6 +392,36 @@ 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 });
|
||||
|
||||
// Qobuz Connect service wiring (2026-08-01 contract §11.5), in order.
|
||||
// This fn is MULTI-ENTRY (login, session restore AND start_offline all
|
||||
// land here), and every caller runs on the tokio runtime, so
|
||||
// `Handle::current()` is valid.
|
||||
// 1. The service singleton — without it `service()` is None and every
|
||||
// arm no-ops silently. OnceLock-idempotent, so the multi-entry seam
|
||||
// calls it every time.
|
||||
let qconnect_service = qconnect_qt::init_service(app());
|
||||
let tokio_handle = tokio::runtime::Handle::current();
|
||||
{
|
||||
// 2. The offline force-disconnect watcher — ONCE per process: the fn
|
||||
// carries NO idempotency guard of its own (unlike the auto-connect
|
||||
// below), so a naive re-spawn per shell entry would leak one
|
||||
// permanent watcher task per entry (double force-disconnect,
|
||||
// double badge flip).
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static OFFLINE_WATCHER_SPAWNED: AtomicBool = AtomicBool::new(false);
|
||||
if !OFFLINE_WATCHER_SPAWNED.swap(true, Ordering::SeqCst) {
|
||||
qconnect_service.spawn_offline_force_disconnect(&tokio_handle);
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// could use it. (The task itself also re-checks offline inside its
|
||||
// retry loop and bails silently.)
|
||||
if !offline_fwd::engine().status().is_offline() {
|
||||
qconnect_qt::spawn_startup_auto_connect(&tokio_handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-login UI state: the shared session entry, then the session header,
|
||||
|
||||
@@ -340,15 +340,22 @@ pub(crate) async fn play_all_tracks(
|
||||
.runtime()
|
||||
.set_queue_source_collection(Some(collection_id.to_string()))
|
||||
.await;
|
||||
if let Err(e) = runtime
|
||||
.core()
|
||||
.play_track_resolved(first_id, crate::playback_qt::current_quality(), None, None, 0)
|
||||
.await
|
||||
{
|
||||
log::error!("[qbz-qt] myqbz_play: play_track {first_id} failed: {e}");
|
||||
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
|
||||
// funnel, before the local audible step). NOT an early return here —
|
||||
// `touch_play` below is frontend-local recency bookkeeping that runs
|
||||
// regardless of where the audio plays (the Slint collection-play flow
|
||||
// records it on the routed path too).
|
||||
if !crate::playback_qt::route_play_to_peer(runtime, first_id).await {
|
||||
if let Err(e) = runtime
|
||||
.core()
|
||||
.play_track_resolved(first_id, crate::playback_qt::current_quality(), None, None, 0)
|
||||
.await
|
||||
{
|
||||
log::error!("[qbz-qt] myqbz_play: play_track {first_id} failed: {e}");
|
||||
}
|
||||
crate::playback_qt::refresh_now_playing(runtime).await;
|
||||
crate::queue_qt::publish(runtime).await;
|
||||
}
|
||||
crate::playback_qt::refresh_now_playing(runtime).await;
|
||||
crate::queue_qt::publish(runtime).await;
|
||||
touch_play(collection_id).await;
|
||||
}
|
||||
|
||||
@@ -468,6 +475,31 @@ pub(crate) fn item_action(source_item_id: String, action: String) {
|
||||
crate::toast_qt::error(qbz_i18n::t("This item resolved to 0 playable tracks"));
|
||||
return;
|
||||
}
|
||||
// Stamp ONCE, ahead of both the routed arm and the local arms: the
|
||||
// blacklist drop must apply to what reaches the PEER exactly as it
|
||||
// does to what lands locally (a blocked track never routes). The
|
||||
// Play arm's `set_queue_stamped` re-runs the same two passes, which
|
||||
// are idempotent (filter drops nothing twice; stamping skips
|
||||
// pre-stamped tracks).
|
||||
let tracks = crate::playback_qt::stamped(tracks, None);
|
||||
|
||||
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
|
||||
// queue — mode-matched to the row's LOCAL placement. Early-returns
|
||||
// when handled, so the local insert + sync tail below only run in
|
||||
// local/renderer mode. (Play replaces the queue instead — routed via
|
||||
// `route_play_to_peer` inside its own arm.)
|
||||
let routed_mode = match mode {
|
||||
RowMode::Play => None,
|
||||
RowMode::PlayNext => Some("next"),
|
||||
RowMode::PlayLater => Some("later"),
|
||||
RowMode::AddToQueue => Some("queue"),
|
||||
};
|
||||
if let Some(routed_mode) = routed_mode {
|
||||
if crate::playback_qt::route_enqueue_to_peer(&tracks, routed_mode).await {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let added_castable = crate::playback_qt::batch_all_qconnect_castable(&tracks);
|
||||
|
||||
match mode {
|
||||
RowMode::Play => {
|
||||
@@ -475,6 +507,11 @@ pub(crate) fn item_action(source_item_id: String, action: String) {
|
||||
// touch_play (per-row action, not "play the whole collection").
|
||||
let first_id = tracks[0].id;
|
||||
crate::playback_qt::set_queue_stamped(&runtime, tracks, Some(0), None).await;
|
||||
// QConnect CONTROLLER mode (§7): route the play to the peer
|
||||
// (after the funnel, before the local audible step).
|
||||
if crate::playback_qt::route_play_to_peer(&runtime, first_id).await {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = runtime
|
||||
.core()
|
||||
.play_track_resolved(
|
||||
@@ -494,26 +531,26 @@ pub(crate) fn item_action(source_item_id: String, action: String) {
|
||||
RowMode::PlayNext => {
|
||||
// REVERSE so the first resolved track lands immediately after
|
||||
// the current one (T12).
|
||||
for track in crate::playback_qt::stamped(tracks, None).into_iter().rev() {
|
||||
for track in tracks.into_iter().rev() {
|
||||
runtime.core().add_track_next(track).await;
|
||||
}
|
||||
crate::playback_qt::sync_qconnect_after_add(added_castable).await;
|
||||
crate::queue_qt::publish(&runtime).await;
|
||||
crate::toast_qt::success(qbz_i18n::t("Playing next"));
|
||||
}
|
||||
RowMode::PlayLater => {
|
||||
// #442: NATURAL order — each insert appends to the END of the
|
||||
// manual block.
|
||||
for track in crate::playback_qt::stamped(tracks, None) {
|
||||
for track in tracks {
|
||||
runtime.core().add_track_later(track).await;
|
||||
}
|
||||
crate::playback_qt::sync_qconnect_after_add(added_castable).await;
|
||||
crate::queue_qt::publish(&runtime).await;
|
||||
crate::toast_qt::success(qbz_i18n::t("Added to play later"));
|
||||
}
|
||||
RowMode::AddToQueue => {
|
||||
runtime
|
||||
.core()
|
||||
.add_tracks(crate::playback_qt::stamped(tracks, None))
|
||||
.await;
|
||||
runtime.core().add_tracks(tracks).await;
|
||||
crate::playback_qt::sync_qconnect_after_add(added_castable).await;
|
||||
crate::queue_qt::publish(&runtime).await;
|
||||
crate::toast_qt::success(qbz_i18n::t("Added to queue"));
|
||||
}
|
||||
@@ -585,11 +622,26 @@ fn bulk_enqueue_inner(items: Vec<MixtapeCollectionItem>, mode: BulkEnqueueMode)
|
||||
}
|
||||
let tracks = crate::playback_qt::stamped(tracks, None);
|
||||
|
||||
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
|
||||
// queue — mode-matched to the bulk bar's LOCAL placement (Next /
|
||||
// Later / Append). Early-returns when handled, so the local insert +
|
||||
// sync tail below only run in local/renderer mode.
|
||||
let routed_mode = match mode {
|
||||
BulkEnqueueMode::Next => "next",
|
||||
BulkEnqueueMode::Later => "later",
|
||||
BulkEnqueueMode::Append => "queue",
|
||||
};
|
||||
if crate::playback_qt::route_enqueue_to_peer(&tracks, routed_mode).await {
|
||||
return;
|
||||
}
|
||||
let added_castable = crate::playback_qt::batch_all_qconnect_castable(&tracks);
|
||||
|
||||
match mode {
|
||||
BulkEnqueueMode::Next => {
|
||||
for track in tracks.into_iter().rev() {
|
||||
runtime.core().add_track_next(track).await;
|
||||
}
|
||||
crate::playback_qt::sync_qconnect_after_add(added_castable).await;
|
||||
crate::queue_qt::publish(&runtime).await;
|
||||
crate::toast_qt::success(qbz_i18n::t("Playing next"));
|
||||
}
|
||||
@@ -597,11 +649,13 @@ fn bulk_enqueue_inner(items: Vec<MixtapeCollectionItem>, mode: BulkEnqueueMode)
|
||||
for track in tracks {
|
||||
runtime.core().add_track_later(track).await;
|
||||
}
|
||||
crate::playback_qt::sync_qconnect_after_add(added_castable).await;
|
||||
crate::queue_qt::publish(&runtime).await;
|
||||
crate::toast_qt::success(qbz_i18n::t("Added to play later"));
|
||||
}
|
||||
BulkEnqueueMode::Append => {
|
||||
runtime.core().add_tracks(tracks).await;
|
||||
crate::playback_qt::sync_qconnect_after_add(added_castable).await;
|
||||
crate::queue_qt::publish(&runtime).await;
|
||||
crate::toast_qt::success(qbz_i18n::t("Added to queue"));
|
||||
}
|
||||
@@ -679,6 +733,11 @@ pub(crate) fn play_inline_track(
|
||||
InlineTrackMode::Play => {
|
||||
let first_id = track.id;
|
||||
crate::playback_qt::set_queue_stamped(&runtime, vec![track], Some(0), None).await;
|
||||
// QConnect CONTROLLER mode (§7): route the play to the peer
|
||||
// (after the funnel, before the local audible step).
|
||||
if crate::playback_qt::route_play_to_peer(&runtime, first_id).await {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = runtime
|
||||
.core()
|
||||
.play_track_resolved(
|
||||
@@ -698,16 +757,37 @@ pub(crate) fn play_inline_track(
|
||||
InlineTrackMode::PlayNext => {
|
||||
let mut stamped = crate::playback_qt::stamped(vec![track], None);
|
||||
if let Some(track) = stamped.pop() {
|
||||
// QConnect CONTROLLER mode (§7): route the insert to the
|
||||
// peer's queue — early-returns when handled (stamped
|
||||
// first: a blacklisted track must not reach the peer when
|
||||
// the local path would drop it).
|
||||
if crate::playback_qt::route_track_to_peer(&track, "next").await {
|
||||
return;
|
||||
}
|
||||
let added_castable =
|
||||
crate::playback_qt::batch_all_qconnect_castable(std::slice::from_ref(&track));
|
||||
runtime.core().add_track_next(track).await;
|
||||
crate::playback_qt::sync_qconnect_after_add(added_castable).await;
|
||||
}
|
||||
crate::queue_qt::publish(&runtime).await;
|
||||
crate::toast_qt::success(qbz_i18n::t("Playing next"));
|
||||
}
|
||||
InlineTrackMode::PlayLater => {
|
||||
runtime
|
||||
.core()
|
||||
.add_tracks(crate::playback_qt::stamped(vec![track], None))
|
||||
.await;
|
||||
let mut stamped = crate::playback_qt::stamped(vec![track], None);
|
||||
if let Some(track) = stamped.pop() {
|
||||
// QConnect CONTROLLER mode (§7): route the add to the
|
||||
// peer's queue. Mode "queue", NOT "later": the local op
|
||||
// here appends via `add_tracks` (the documented inline/row
|
||||
// divergence above), so the route matches what the LOCAL
|
||||
// path does.
|
||||
if crate::playback_qt::route_track_to_peer(&track, "queue").await {
|
||||
return;
|
||||
}
|
||||
let added_castable =
|
||||
crate::playback_qt::batch_all_qconnect_castable(std::slice::from_ref(&track));
|
||||
runtime.core().add_tracks(vec![track]).await;
|
||||
crate::playback_qt::sync_qconnect_after_add(added_castable).await;
|
||||
}
|
||||
crate::queue_qt::publish(&runtime).await;
|
||||
crate::toast_qt::success(qbz_i18n::t("Added to queue"));
|
||||
}
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
//! (`set_effective_stream`, only when the delivered params actually move).
|
||||
//! Without those edges the stamp only refreshed when the user changed page.
|
||||
//!
|
||||
//! POC-NOTE: playback transport is wired (playback_qt poll pump). Cast /
|
||||
//! Qobuz Connect are NOT in the POC build (no qbz-cast / qconnect deps), so
|
||||
//! `set_cast_session` / `set_remote` exist as the wiring seam and the flags
|
||||
//! stay at their idle defaults until a session service calls them.
|
||||
//! POC-NOTE: playback transport is wired (playback_qt poll pump). Cast is NOT
|
||||
//! in the POC build (no qbz-cast dep), so `set_cast_session` exists as the
|
||||
//! wiring seam; `set_remote` / `set_remote_volume_locked` ARE live — written
|
||||
//! by the Qobuz Connect port (qconnect_event_sink_qt's badge refresh + the
|
||||
//! facade's disconnect tail).
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -73,6 +74,11 @@ pub struct NowPlayingModel {
|
||||
pub eff_bits: u32,
|
||||
/// A peer Qobuz Connect renderer owns playback.
|
||||
pub is_remote: bool,
|
||||
/// The active PEER renderer disallows remote volume control (the remote
|
||||
/// half of Slint `NowPlayingState.volume-locked`, contract §11.3). Kept
|
||||
/// OUT of the settings-derived `np_volume_locked` — that one is
|
||||
/// republished on every settings/track edge and would clobber this.
|
||||
pub remote_volume_locked: bool,
|
||||
/// Active renderer / cast target name; empty when local.
|
||||
pub cast_target: String,
|
||||
/// A Chromecast/DLNA session is connected.
|
||||
@@ -160,6 +166,8 @@ fn publish(m: &NowPlayingModel) {
|
||||
.set_np_quality_limit_cause(m.delivered.limit_cause);
|
||||
// --- Cast / remote ------------------------------------------------
|
||||
b.as_mut().set_np_is_remote(m.is_remote);
|
||||
b.as_mut()
|
||||
.set_np_remote_volume_locked(m.remote_volume_locked);
|
||||
b.as_mut()
|
||||
.set_np_cast_target(QString::from(m.cast_target.as_str()));
|
||||
b.as_mut().set_np_cast_active(m.cast_active);
|
||||
@@ -340,6 +348,7 @@ pub fn clear_track() {
|
||||
// would carry a 0.3 lock into the next track's first tick.
|
||||
seekable_max: 1.0,
|
||||
is_remote: m.is_remote,
|
||||
remote_volume_locked: m.remote_volume_locked,
|
||||
cast_target: m.cast_target.clone(),
|
||||
cast_active: m.cast_active,
|
||||
cast_protocol: m.cast_protocol.clone(),
|
||||
@@ -367,11 +376,24 @@ pub fn set_cast_session(active: bool, protocol: &str, target: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
/// A peer Qobuz Connect renderer took over (or handed back) the transport
|
||||
/// (`qconnect_event_sink.rs` `refresh_transport_modes`). `target` is the
|
||||
/// renderer's friendly name, empty when local.
|
||||
#[allow(dead_code)] // wired by the poll pump / a cast service (see GLUE)
|
||||
/// A peer Qobuz Connect renderer took over (or handed back) the transport.
|
||||
/// THE writer is the qconnect sink's `refresh_now_playing_remote_state`
|
||||
/// (`qconnect_event_sink_qt.rs`, gated on `transport_connected` — the
|
||||
/// stale-badge fix); the facade's disconnect tail is the second write site.
|
||||
/// `target` is the renderer's friendly name, empty when local.
|
||||
pub fn set_remote(is_remote: bool, target: &str) {
|
||||
if !is_remote {
|
||||
// Contract §11.1: remote mode ended — drop the lyrics remote anchor.
|
||||
// This setter is the ONE choke point every remote-end path funnels
|
||||
// through (the qconnect sink's badge refresh and the facade's
|
||||
// disconnect tail — which the cast suspend also rides), so the clear
|
||||
// lives here instead of the Slint's per-tick poll-loop site
|
||||
// (playback.rs:5279): the Qt lyrics getters gate on THIS model flag
|
||||
// rather than a lyrics-side ACTIVE atomic, and the poll loop's local
|
||||
// fallthrough is skipped while the cast branch runs — exactly the
|
||||
// window where a stale anchor would otherwise linger.
|
||||
crate::lyrics_qt::clear_remote_anchor();
|
||||
}
|
||||
mutate(|m| {
|
||||
m.is_remote = is_remote;
|
||||
if is_remote {
|
||||
@@ -382,6 +404,21 @@ pub fn set_remote(is_remote: bool, target: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Read-only: a peer Qobuz Connect renderer owns playback. The lyrics sync
|
||||
/// engine gates its remote-anchor read on this (contract §11.1).
|
||||
pub fn is_remote() -> bool {
|
||||
with_model(|m| m.is_remote).0
|
||||
}
|
||||
|
||||
/// The REMOTE volume lock (contract §11.3): the active peer Qobuz Connect
|
||||
/// renderer disallows remote volume control. Written from the qconnect sink's
|
||||
/// badge refresh alongside `set_remote`, and cleared by the facade's
|
||||
/// disconnect tail — publish onto `QbzPlayer.np_remote_volume_locked` (never
|
||||
/// folded into the settings-derived `np_volume_locked`).
|
||||
pub fn set_remote_volume_locked(locked: bool) {
|
||||
mutate(|m| m.remote_volume_locked = locked);
|
||||
}
|
||||
|
||||
/// The DELIVERED quality measured by a cast session, which the local poll
|
||||
/// cannot see (the local player is stopped while casting —
|
||||
/// `cast_service.rs` `publish_delivered_quality`). `limit_cause` is a
|
||||
@@ -401,6 +438,17 @@ pub fn set_artwork_path(path: String) {
|
||||
|
||||
/// 1 Hz position push from the poll pump. `has_audio` = the engine
|
||||
/// surfaced a track id (clears the loading spinner).
|
||||
///
|
||||
/// Standalone `loading = false` publish for the paths that never reach a
|
||||
/// position push — the QConnect routed-play REFUSAL case (mixed queue refused
|
||||
/// by the peer, §12.29): the Slint clears PENDING_PLAY_ID right after
|
||||
/// `play_on_peer_if_active` (playback.rs:643-650); here the poll loop's peer
|
||||
/// branch normally clears the spinner via `set_position(has_audio = true)`,
|
||||
/// but a refused play never primes the peer, so a paused+idle peer would
|
||||
/// leave the spinner latched.
|
||||
pub(crate) fn clear_loading() {
|
||||
mutate(|m| m.loading = false);
|
||||
}
|
||||
pub fn set_position(
|
||||
elapsed_secs: i32,
|
||||
duration_secs: i32,
|
||||
|
||||
@@ -303,6 +303,14 @@ pub(crate) async fn set_queue_stamped(
|
||||
stamp_context(&mut tracks, context);
|
||||
warn_dead_context(&tracks, "set_queue");
|
||||
runtime.core().set_queue(tracks, start).await;
|
||||
// QConnect (contract §6.3, Slint playback.rs:3697-3703): when WE are the
|
||||
// active renderer, push the new queue to the session immediately so a
|
||||
// controller's UI reflects the freshly-built queue — covers infinite-play
|
||||
// refills + album/radio plays. Self-gates to a no-op when not connected
|
||||
// or when a peer owns playback.
|
||||
if let Some(svc) = crate::qconnect_qt::service() {
|
||||
svc.sync_local_queue_if_changed().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Same guarantee for the ADD paths (play-next / add-to-queue): appended tracks
|
||||
@@ -332,6 +340,109 @@ pub(crate) fn stamped(tracks: Vec<QueueTrack>, context: Option<PlayContext>) ->
|
||||
tracks
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QConnect routing helpers (contract §6.3/§7 — Slint playback.rs:4082-4111
|
||||
// plus the routed arms on every Qobuz-castable add path)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// QConnect sync-on-add predicate (#442, playback.rs:4082-4096): true when
|
||||
/// EVERY track of an added batch is Qobuz-castable (mirrors
|
||||
/// `sync_local_queue_if_changed`'s all-or-nothing admission — `local` /
|
||||
/// `plex` refused, offline `qobuz_download` eligible). A non-castable batch
|
||||
/// skips the post-add push so a local/plex add never trips the refusal toast
|
||||
/// on every click; the next track tick already surfaces that state.
|
||||
pub(crate) fn batch_all_qconnect_castable(tracks: &[QueueTrack]) -> bool {
|
||||
tracks.iter().all(|qt| {
|
||||
qt.id > 0
|
||||
&& !matches!(
|
||||
qt.source.as_deref().map(|s| s.to_ascii_lowercase()).as_deref(),
|
||||
Some("local") | Some("plex")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// QConnect sync-on-add (#442, playback.rs:4098-4111): after a local queue
|
||||
/// ADD (play-next / play-later / add-to-queue), push the updated queue to the
|
||||
/// session immediately so a connected controller sees the new item AT its
|
||||
/// position — previously it only re-synced on the next track transition.
|
||||
/// `sync_local_queue_if_changed` self-gates (no-op when not connected, when a
|
||||
/// peer owns playback, or when the queue is unchanged), so this is cheap in
|
||||
/// every non-renderer situation.
|
||||
pub(crate) async fn sync_qconnect_after_add(added_castable: bool) {
|
||||
if !added_castable {
|
||||
return;
|
||||
}
|
||||
if let Some(svc) = crate::qconnect_qt::service() {
|
||||
svc.sync_local_queue_if_changed().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// QConnect CONTROLLER-mode enqueue routing (contract §7 — the Slint add-path
|
||||
/// arms, e.g. playback.rs:3012-3023, 4116-4128): when a PEER renderer owns
|
||||
/// playback, route the add to the peer's queue instead of mutating only the
|
||||
/// LOCAL queue (the peer never sees a local-only enqueue; the cloud echoes a
|
||||
/// QueueUpdated that `materialize_remote_queue` applies). Mode-matched to the
|
||||
/// caller's LOCAL placement: "next" inserts after the peer's current track,
|
||||
/// "later" lands on the peer's manual-block tail, anything else appends.
|
||||
/// Returns `true` when handled remotely — the caller MUST NOT enqueue
|
||||
/// locally, and its sync tail does not run (the peer owns the queue now).
|
||||
pub(crate) async fn route_enqueue_to_peer(tracks: &[QueueTrack], mode: &str) -> bool {
|
||||
let Some(svc) = crate::qconnect_qt::service() else {
|
||||
return false;
|
||||
};
|
||||
let routed: Vec<(u64, Option<String>)> = tracks
|
||||
.iter()
|
||||
.map(|qt| (qt.id, qt.source.clone()))
|
||||
.collect();
|
||||
match mode {
|
||||
"next" => svc.play_next_batch_on_peer_if_active(&routed).await,
|
||||
"later" => svc.play_later_batch_on_peer_if_active(&routed).await,
|
||||
_ => svc.add_to_queue_batch_on_peer_if_active(&routed).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-track variant of [`route_enqueue_to_peer`] (Slint
|
||||
/// playback.rs:4120-4128's single-track arm).
|
||||
pub(crate) async fn route_track_to_peer(track: &QueueTrack, mode: &str) -> bool {
|
||||
let Some(svc) = crate::qconnect_qt::service() else {
|
||||
return false;
|
||||
};
|
||||
match mode {
|
||||
"next" => svc.play_next_on_peer_if_active(track.id, track.source.as_deref()).await,
|
||||
"later" => svc.play_later_on_peer_if_active(track.id, track.source.as_deref()).await,
|
||||
_ => svc.add_to_queue_on_peer_if_active(track.id, track.source.as_deref()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// QConnect CONTROLLER-mode play routing (contract §7's decided arm position —
|
||||
/// Slint playback.rs:638-651): when a PEER renderer owns playback, route the
|
||||
/// play to the peer instead of running the local audible step. Call AFTER
|
||||
/// `set_queue_stamped` (the arm pushes the CURRENT core queue — placed before
|
||||
/// the funnel it would push the STALE one) and BEFORE `play_track_resolved`.
|
||||
/// On handled, refreshes the bar + queue from the core cursor (the Slint
|
||||
/// `after_track_change` meta refresh + sidebar) and returns `true` — the
|
||||
/// caller early-returns and MUST NOT play locally. The Slint's `clear_loading`
|
||||
/// after `play_on_peer_if_active` (playback.rs:643-650) maps to
|
||||
/// `now_playing::clear_loading()` here: the poll loop's peer branch normally
|
||||
/// clears the spinner via its position push (`has_audio = true`) on the next
|
||||
/// tick, but a REFUSED routed play (mixed queue) never primes the peer — a
|
||||
/// paused+idle peer would leave the spinner latched (sign-off F1).
|
||||
pub(crate) async fn route_play_to_peer(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
track_id: u64,
|
||||
) -> bool {
|
||||
let Some(svc) = crate::qconnect_qt::service() else {
|
||||
return false;
|
||||
};
|
||||
if !svc.play_on_peer_if_active(track_id).await {
|
||||
return false;
|
||||
}
|
||||
crate::now_playing::clear_loading();
|
||||
refresh_now_playing(runtime).await;
|
||||
publish_queue(runtime).await;
|
||||
true
|
||||
}
|
||||
|
||||
/// Tripwire: a track with NEITHER a container origin NOR an album id publishes
|
||||
/// `ctx-id == ""` (refresh_now_playing's fallback has nothing to fall back to),
|
||||
/// which renders the song-card layers glyph as a dead control. Nothing in the
|
||||
@@ -498,6 +609,11 @@ pub async fn play_artist(runtime: &Arc<AppRuntime<LoggingAdapter>>, artist_id: &
|
||||
let first_id = top[0].id;
|
||||
set_queue_stamped(runtime, top, Some(0), PlayContext::artist(artist_id)).await;
|
||||
publish_queue(runtime).await;
|
||||
// QConnect CONTROLLER mode (§7): a peer owns playback — route the play
|
||||
// to it AFTER the funnel, never run the local audible step.
|
||||
if route_play_to_peer(runtime, first_id).await {
|
||||
return Ok(());
|
||||
}
|
||||
runtime
|
||||
.core()
|
||||
.play_track_resolved(first_id, current_quality(), None, None, 0)
|
||||
@@ -544,6 +660,11 @@ pub async fn play_artist(runtime: &Arc<AppRuntime<LoggingAdapter>>, artist_id: &
|
||||
}
|
||||
set_queue_stamped(runtime, queue, Some(0), PlayContext::artist(artist_id)).await;
|
||||
publish_queue(runtime).await;
|
||||
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
|
||||
// funnel, before the local audible step).
|
||||
if route_play_to_peer(runtime, first_id).await {
|
||||
return Ok(());
|
||||
}
|
||||
runtime
|
||||
.core()
|
||||
.play_track_resolved(first_id, current_quality(), None, None, 0)
|
||||
@@ -587,6 +708,16 @@ pub async fn enqueue_album(
|
||||
PlayContext::album(album_id),
|
||||
);
|
||||
log::info!("[qbz-qt] enqueue_album {album_id} ({mode}): {} tracks", tracks.len());
|
||||
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
|
||||
// queue — early-returns when handled, so the local insert + sync tail
|
||||
// below only run in local/renderer mode. The mode is normalized to what
|
||||
// THIS fn's local match does: only "next" inserts at the cursor, every
|
||||
// other mode appends (there is no block-tail arm here).
|
||||
let routed_mode = if mode == "next" { "next" } else { "queue" };
|
||||
if route_enqueue_to_peer(&tracks, routed_mode).await {
|
||||
return Ok(());
|
||||
}
|
||||
let added_castable = batch_all_qconnect_castable(&tracks);
|
||||
if mode == "next" {
|
||||
// add_track_next inserts directly after the current track — feed
|
||||
// in reverse so the album's first track lands first.
|
||||
@@ -596,6 +727,10 @@ pub async fn enqueue_album(
|
||||
} else {
|
||||
runtime.core().add_tracks(tracks).await;
|
||||
}
|
||||
// QConnect sync-on-add (#442): push the updated queue to the session so a
|
||||
// connected controller sees the new items; skipped silently for a
|
||||
// non-castable batch (self-gating when a peer owns playback).
|
||||
sync_qconnect_after_add(added_castable).await;
|
||||
publish_queue(runtime).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -632,6 +767,11 @@ pub async fn play_album_from(
|
||||
set_queue_stamped(runtime, tracks, Some(start), PlayContext::album(album_id)).await;
|
||||
publish_queue(runtime).await;
|
||||
log::info!("[qbz-qt] play_album: queue set ({count} tracks), playing track {first_id}");
|
||||
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
|
||||
// funnel, before the local audible step).
|
||||
if route_play_to_peer(runtime, first_id).await {
|
||||
return Ok(());
|
||||
}
|
||||
runtime
|
||||
.core()
|
||||
.play_track_resolved(first_id, current_quality(), None, None, 0)
|
||||
@@ -665,6 +805,11 @@ pub async fn play_album_from_track(
|
||||
let first_id = tracks[start].id;
|
||||
set_queue_stamped(runtime, tracks, Some(start), PlayContext::album(album_id)).await;
|
||||
publish_queue(runtime).await;
|
||||
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
|
||||
// funnel, before the local audible step).
|
||||
if route_play_to_peer(runtime, first_id).await {
|
||||
return Ok(());
|
||||
}
|
||||
runtime
|
||||
.core()
|
||||
.play_track_resolved(first_id, current_quality(), None, None, 0)
|
||||
@@ -775,6 +920,11 @@ pub async fn play_track_list_in(
|
||||
let first_id = tracks[start].id;
|
||||
set_queue_stamped(runtime, tracks, Some(start), context).await;
|
||||
publish_queue(runtime).await;
|
||||
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
|
||||
// funnel, before the local audible step).
|
||||
if route_play_to_peer(runtime, first_id).await {
|
||||
return Ok(());
|
||||
}
|
||||
runtime
|
||||
.core()
|
||||
.play_track_resolved(first_id, current_quality(), None, None, 0)
|
||||
@@ -871,6 +1021,13 @@ pub async fn enqueue_track_list_mode(
|
||||
return Err("empty track list".to_string());
|
||||
}
|
||||
let tracks = stamped(tracks, None);
|
||||
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
|
||||
// queue — early-returns when handled, so the local insert + sync tail
|
||||
// below only run in local/renderer mode.
|
||||
if route_enqueue_to_peer(&tracks, mode).await {
|
||||
return Ok(());
|
||||
}
|
||||
let added_castable = batch_all_qconnect_castable(&tracks);
|
||||
match mode {
|
||||
"next" => {
|
||||
for track in tracks.into_iter().rev() {
|
||||
@@ -884,6 +1041,9 @@ pub async fn enqueue_track_list_mode(
|
||||
}
|
||||
_ => runtime.core().add_tracks(tracks).await,
|
||||
}
|
||||
// QConnect sync-on-add (#442): push the updated queue to the session;
|
||||
// skipped silently for a non-castable batch.
|
||||
sync_qconnect_after_add(added_castable).await;
|
||||
publish_queue(runtime).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -891,6 +1051,10 @@ pub async fn enqueue_track_list_mode(
|
||||
/// ArtistView ⋯ "Play all next" / "Add all to queue" — the whole body, so the
|
||||
/// invokable in `player_bridge.rs` can call it without a `main.rs` shim (see
|
||||
/// GLUE in the report).
|
||||
///
|
||||
/// The QConnect routed arm + sync tail live in the delegate
|
||||
/// (`enqueue_track_list_mode`) — arming here too would double-fire the routed
|
||||
/// send whenever a peer is active.
|
||||
pub async fn enqueue_artist_top_mode(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
mode: &str,
|
||||
@@ -925,11 +1089,23 @@ pub async fn enqueue_album_track(
|
||||
let Some(track) = tracks.into_iter().find(|t| t.id == track_id) else {
|
||||
return Err(format!("track {track_id} not in album {album_id}"));
|
||||
};
|
||||
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
|
||||
// queue — early-returns when handled. The mode is normalized to what THIS
|
||||
// fn's local match does: only "next" inserts at the cursor, every other
|
||||
// mode appends (no block-tail arm here).
|
||||
let routed_mode = if mode == "next" { "next" } else { "queue" };
|
||||
if route_track_to_peer(&track, routed_mode).await {
|
||||
return Ok(());
|
||||
}
|
||||
let added_castable = batch_all_qconnect_castable(std::slice::from_ref(&track));
|
||||
if mode == "next" {
|
||||
runtime.core().add_track_next(track).await;
|
||||
} else {
|
||||
runtime.core().add_track(track).await;
|
||||
}
|
||||
// QConnect sync-on-add (#442): push the updated queue to the session;
|
||||
// skipped silently for a non-castable add.
|
||||
sync_qconnect_after_add(added_castable).await;
|
||||
publish_queue(runtime).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1087,6 +1263,11 @@ pub async fn play_single_track(
|
||||
set_queue_stamped(runtime, vec![qt], Some(0), None).await;
|
||||
publish_queue(runtime).await;
|
||||
log::info!("[qbz-qt] play_single_track: playing {track_id}");
|
||||
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
|
||||
// funnel, before the local audible step).
|
||||
if route_play_to_peer(runtime, track_id).await {
|
||||
return Ok(());
|
||||
}
|
||||
runtime
|
||||
.core()
|
||||
.play_track_resolved(track_id, current_quality(), None, None, 0)
|
||||
@@ -1107,11 +1288,21 @@ pub async fn enqueue_single_track(
|
||||
let qt = stamped(vec![queue_track_for(runtime, track_id).await?], None)
|
||||
.pop()
|
||||
.expect("one track in, one track out");
|
||||
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
|
||||
// queue — early-returns when handled, so the local insert + sync tail
|
||||
// below only run in local/renderer mode.
|
||||
if route_track_to_peer(&qt, mode).await {
|
||||
return Ok(());
|
||||
}
|
||||
let added_castable = batch_all_qconnect_castable(std::slice::from_ref(&qt));
|
||||
match mode {
|
||||
"next" => runtime.core().add_track_next(qt).await,
|
||||
"later" => runtime.core().add_track_later(qt).await,
|
||||
_ => runtime.core().add_track(qt).await,
|
||||
}
|
||||
// QConnect sync-on-add (#442): push the updated queue to the session;
|
||||
// skipped silently for a non-castable add.
|
||||
sync_qconnect_after_add(added_castable).await;
|
||||
publish_queue(runtime).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1130,6 +1321,21 @@ pub async fn toggle_play(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// QConnect CONTROLLER mode (dispatch priority cast → QConnect → local,
|
||||
// Slint main.rs:14238-14247): a peer renderer owns playback — toggle IT.
|
||||
// `true` = handled remotely; the local path MUST NOT run. On error there
|
||||
// is no local fallback (a peer owns audio; a local resume would
|
||||
// double-play).
|
||||
if let Some(svc) = crate::qconnect_qt::service() {
|
||||
match svc.toggle_remote_renderer_playback_if_active().await {
|
||||
Ok(true) => return,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt][QConnect] toggle-play handoff: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let event = runtime.core().player().get_playback_event();
|
||||
let result = if event.is_playing {
|
||||
runtime.core().pause()
|
||||
@@ -1142,12 +1348,38 @@ pub async fn toggle_play(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
}
|
||||
|
||||
pub async fn next(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
// QConnect CONTROLLER mode (Slint main.rs:14268-14277): a peer renderer
|
||||
// owns playback — skip on IT and return; the local cursor stays put (the
|
||||
// cloud echo re-materializes the queue). NOTE: no cast branch here, 1:1
|
||||
// with the reference (main.rs:14261-14267) — while casting, the local
|
||||
// next() flow runs and the play arm casts the new current track.
|
||||
if let Some(svc) = crate::qconnect_qt::service() {
|
||||
match svc.skip_next_if_remote().await {
|
||||
Ok(true) => return,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt][QConnect] next handoff: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(track) = runtime.core().next_track().await {
|
||||
play_queue_track(runtime, track.id).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn previous(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
// QConnect CONTROLLER mode (Slint main.rs:14293-14302) — see next() above.
|
||||
if let Some(svc) = crate::qconnect_qt::service() {
|
||||
match svc.skip_previous_if_remote().await {
|
||||
Ok(true) => return,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt][QConnect] previous handoff: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(track) = runtime.core().previous_track().await {
|
||||
play_queue_track(runtime, track.id).await;
|
||||
}
|
||||
@@ -1165,6 +1397,13 @@ async fn play_queue_track(runtime: &Arc<AppRuntime<LoggingAdapter>>, track_id: u
|
||||
publish_queue(runtime).await;
|
||||
return;
|
||||
}
|
||||
// QConnect CONTROLLER mode (Slint playback.rs:638-651, AFTER the cast
|
||||
// gate, BEFORE the local arms): a peer renderer owns playback — route the
|
||||
// play to it (it pushes the current core queue, which the caller's cursor
|
||||
// move already updated) and never start the local backend.
|
||||
if route_play_to_peer(runtime, track_id).await {
|
||||
return;
|
||||
}
|
||||
// Source-aware audible step: a LOCAL file plays from disk through the
|
||||
// player's play_data seam. The Qobuz tier-walk below needs a client and
|
||||
// would fail with "No Qobuz client available" on every local advance.
|
||||
@@ -1283,6 +1522,24 @@ pub async fn seek_frac(runtime: &Arc<AppRuntime<LoggingAdapter>>, frac: f32) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// QConnect CONTROLLER mode (Slint main.rs:14334-14351): seek the REMOTE
|
||||
// renderer. The remote API wants absolute position in MILLISECONDS; the
|
||||
// bar gives a 0..1 fraction — derive ms from the locally-known duration
|
||||
// (SECONDS here, hence the ×1000).
|
||||
if let Some(svc) = crate::qconnect_qt::service() {
|
||||
let fraction = frac.clamp(0.0, 1.0);
|
||||
let duration_secs = runtime.core().get_playback_state().duration;
|
||||
let position_ms =
|
||||
(fraction as f64 * duration_secs as f64 * 1000.0).round() as i64;
|
||||
match svc.set_position_if_remote(position_ms).await {
|
||||
Ok(true) => return,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt][QConnect] seek handoff: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let event = runtime.core().player().get_playback_event();
|
||||
if event.duration == 0 {
|
||||
return;
|
||||
@@ -1297,11 +1554,42 @@ pub async fn set_volume(runtime: &Arc<AppRuntime<LoggingAdapter>>, volume: f32)
|
||||
if matches!(crate::cast_qt::service().set_volume_if_cast(volume).await, Ok(true)) {
|
||||
return;
|
||||
}
|
||||
// QConnect CONTROLLER mode (Slint main.rs:14378-14389): set the REMOTE
|
||||
// renderer's volume. The remote API wants 0..100; the bar gives a 0..1
|
||||
// fraction. TRAP (§12.30): a volume-DISALLOWING peer returns Ok(true) —
|
||||
// handled NO-OP — so a drag on a volume-locked peer never falls through
|
||||
// to change the LOCAL volume.
|
||||
if let Some(svc) = crate::qconnect_qt::service() {
|
||||
let remote_volume = (volume.clamp(0.0, 1.0) * 100.0).round() as i32;
|
||||
match svc.set_volume_if_remote(remote_volume).await {
|
||||
Ok(true) => return,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt][QConnect] set-volume handoff: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = runtime.core().set_volume(volume.clamp(0.0, 1.0));
|
||||
MUTED.store(volume <= 0.0 && PREMUTE_VOLUME.load(Ordering::Relaxed) != 0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub async fn toggle_mute(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
// QConnect FIRST (Slint main.rs:14455-14478 — the ONE dispatch that checks
|
||||
// QConnect before cast; the reference has NO cast arm for mute). The
|
||||
// remote API wants the target value; send the negation of the
|
||||
// authoritative local MUTED flag.
|
||||
if let Some(svc) = crate::qconnect_qt::service() {
|
||||
let target = !MUTED.load(Ordering::Relaxed);
|
||||
match svc.mute_if_remote(target).await {
|
||||
Ok(true) => return,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt][QConnect] toggle-mute handoff: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if MUTED.swap(false, Ordering::Relaxed) {
|
||||
// Unmute — restore the stored level.
|
||||
let restored = f32::from_bits(PREMUTE_VOLUME.load(Ordering::Relaxed) as u32);
|
||||
@@ -1320,12 +1608,39 @@ pub async fn toggle_mute(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
}
|
||||
|
||||
pub async fn toggle_shuffle(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
// QConnect (Slint main.rs:14492-14501 — no cast arm in the reference):
|
||||
// the CLOUD owns shuffle order while connected (gated on
|
||||
// transport_connected inside the service — §12.14, no extra gate here);
|
||||
// QBZ applies only the echo. `true` = handled remotely — a local reorder
|
||||
// would diverge from the cloud's order.
|
||||
if let Some(svc) = crate::qconnect_qt::service() {
|
||||
match svc.toggle_shuffle_if_remote().await {
|
||||
Ok(true) => return,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt][QConnect] toggle-shuffle handoff: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let enabled = runtime.core().toggle_shuffle().await;
|
||||
crate::now_playing::set_shuffle(enabled);
|
||||
publish_queue(runtime).await;
|
||||
}
|
||||
|
||||
pub async fn cycle_repeat(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
// QConnect (Slint main.rs:14517-14526 — no cast arm in the reference):
|
||||
// gated on transport_connected inside the service (§12.14).
|
||||
if let Some(svc) = crate::qconnect_qt::service() {
|
||||
match svc.cycle_repeat_if_remote().await {
|
||||
Ok(true) => return,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt][QConnect] cycle-repeat handoff: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let next_mode = match crate::now_playing::repeat_mode() {
|
||||
0 => RepeatMode::All,
|
||||
1 => RepeatMode::One,
|
||||
@@ -1473,6 +1788,15 @@ pub async fn publish_queue(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
|
||||
static POLL_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Wall-clock now in ms — the peer-position extrapolation clock (the Slint
|
||||
/// `now_ms`, playback.rs:5162).
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Start the 1 Hz pump on shell entry (idempotent). Publishes position /
|
||||
/// playing / cache into the NowPlayingModel, detects track changes (meta +
|
||||
/// queue refresh), and advances the queue at end-of-track through the core.
|
||||
@@ -1488,6 +1812,35 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
// does not re-request it on every tick while the download is in flight
|
||||
// (playback.rs:5049-5051).
|
||||
let mut gapless_requested_for: u64 = 0;
|
||||
|
||||
// QConnect renderer-report throttle (contract §6.2): the official
|
||||
// client reports RndrSrvrStateUpdated ~every 2s while playing PLUS
|
||||
// immediately on a transition (track / play-state change). The Slint
|
||||
// ticks at 450ms and reports every 4 ticks ≈ 1.8s
|
||||
// (playback.rs:5053-5059); this loop ticks at 1 Hz, so the wall-clock
|
||||
// equivalent is every 2 ticks — tick-count throttles do NOT translate
|
||||
// 1:1, translate by wall time.
|
||||
let mut last_reported_track_id: u64 = 0;
|
||||
let mut last_reported_playing = false;
|
||||
let mut report_tick: u64 = 0;
|
||||
const QCONNECT_REPORT_EVERY_N_TICKS: u64 = 2;
|
||||
|
||||
// QConnect CONTROLLER mode (contract §6.1): the peer's last-seen
|
||||
// current track id. When the peer advances a track on its own, this
|
||||
// edge-detects the change so the bar/queue meta refresh from the core
|
||||
// cursor (which the sink already aligned to the peer's track). Reset
|
||||
// to 0 when the peer branch is not taken so re-entering controller
|
||||
// mode refreshes meta (playback.rs:5061-5066).
|
||||
let mut last_peer_track_id: u64 = 0;
|
||||
// Dirty-guard for the per-tick peer UI pushes (playback.rs:5194-5204):
|
||||
// (track_id, position_ms, duration_secs, playing, volume f32 bits,
|
||||
// shuffle, repeat_mode). Re-pushing identical values every second
|
||||
// dirties bindings and repaints even when fully idle. `track_id` is
|
||||
// load-bearing: it guarantees the push after a peer track change (the
|
||||
// meta refresh just reset the bar's position to 0). Reset to None
|
||||
// whenever another owner (local/cast poll) may have written the bar.
|
||||
let mut last_remote_ui_push: Option<(u64, u64, u64, bool, u32, bool, i32)> = None;
|
||||
|
||||
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(1));
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
@@ -1510,6 +1863,146 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
crate::toast_qt::error(stream_error_text(&msg, sandboxed));
|
||||
}
|
||||
|
||||
// --- QConnect CONTROLLER mode: peer-state reflection ----------
|
||||
// (contract §6.1 — Slint playback.rs:5117-5244, inserted BEFORE
|
||||
// the cast branch exactly like the reference.) When QBZ is
|
||||
// CONTROLLING a peer renderer, the event sink stops the LOCAL
|
||||
// player, so `get_playback_event()` reports track_id == 0 / not
|
||||
// playing and the seek bar would freeze. While a peer owns
|
||||
// playback, drive the bar from the peer's renderer snapshot
|
||||
// instead: title / artist / art come from the materialized local
|
||||
// core queue (the sink aligns the core cursor to the peer's
|
||||
// track), only position / playing / duration come from the peer.
|
||||
// Returns None in every NON-controller situation (disconnected,
|
||||
// renderer mode where active == local, no active renderer), so
|
||||
// the local path below runs byte-unchanged.
|
||||
//
|
||||
// UNITS (contract §6): `RemoteNowPlaying.position_ms` /
|
||||
// `updated_at_ms` are MILLISECONDS; `now_playing::set_position`
|
||||
// takes SECONDS. Convert at the publish boundary.
|
||||
if let Some(remote) = match crate::qconnect_qt::service() {
|
||||
Some(svc) => svc.remote_now_playing().await,
|
||||
None => None,
|
||||
} {
|
||||
// The peer changed track on its own → refresh the bar/queue
|
||||
// meta from the core cursor (the event sink already aligned
|
||||
// it to the peer's track via sync_active_renderer_projection).
|
||||
// This resets position to 0, which is correct on a track
|
||||
// change; the per-tick position push below immediately
|
||||
// re-applies the peer's real position. Done BEFORE the
|
||||
// per-tick push so peer values win.
|
||||
if remote.track_id != last_peer_track_id {
|
||||
refresh_now_playing(&runtime).await;
|
||||
publish_queue(&runtime).await;
|
||||
// Discord follows the peer (§11.2): the peer track edge
|
||||
// pushes Discord but NEVER scrobbles — 1:1 with the
|
||||
// Slint, where this edge reaches the UNGUARDED push
|
||||
// inside refresh_now_playing_meta (playback.rs:5137-5141
|
||||
// -> :2236-2239). The scrobble half of the LOCAL edge is
|
||||
// peer-guarded inside `on_track_change_edge` instead.
|
||||
crate::integrations_qt::discord_push(&runtime);
|
||||
last_peer_track_id = remote.track_id;
|
||||
}
|
||||
// Lyrics follow the peer (Q7, §11.1): publish the RAW
|
||||
// renderer anchor on EVERY peer-branch tick — the lyrics
|
||||
// sync invokables extrapolate between poll ticks exactly
|
||||
// like the position extrapolation below
|
||||
// (playback.rs:5142-5149). Outside the track edge AND the
|
||||
// dirty guard on purpose: re-anchoring a peer pause promptly
|
||||
// beats a skipped UI push.
|
||||
crate::lyrics_qt::publish_remote_anchor(
|
||||
remote.position_ms,
|
||||
remote.updated_at_ms,
|
||||
remote.playing,
|
||||
);
|
||||
// Duration from the core queue's current track (aligned to the
|
||||
// peer's track by the sink). Zero when unknown — the clamp is
|
||||
// skipped then; without it the seekbar is wrong.
|
||||
let duration_secs = runtime
|
||||
.core()
|
||||
.current_track()
|
||||
.await
|
||||
.map(|track| track.duration_secs)
|
||||
.unwrap_or(0);
|
||||
let duration_ms = duration_secs.saturating_mul(1000);
|
||||
// Extrapolate position while playing; clamp to the track length.
|
||||
let mut position_ms = remote.position_ms;
|
||||
if remote.playing && remote.updated_at_ms > 0 {
|
||||
position_ms = position_ms
|
||||
.saturating_add(now_ms().saturating_sub(remote.updated_at_ms));
|
||||
}
|
||||
if duration_ms > 0 && position_ms > duration_ms {
|
||||
position_ms = duration_ms;
|
||||
}
|
||||
// ms → s at the publish boundary (see UNITS above).
|
||||
let position_secs = position_ms / 1000;
|
||||
let playing = remote.playing;
|
||||
// Reflect the PEER's actual volume on the bar so a drag starts
|
||||
// from a safe level (never QBZ's local 100). When the peer
|
||||
// hasn't reported a volume, clamp to 50% — the AVR-nuke
|
||||
// safety default (playback.rs:5174-5180, §12.13).
|
||||
let remote_volume = remote
|
||||
.volume
|
||||
.map(|v| (v as f32 / 100.0).clamp(0.0, 1.0))
|
||||
.unwrap_or(0.5);
|
||||
// Reflect the PEER's shuffle/repeat state on the bar buttons.
|
||||
// Pure UI reflection of the cloud's reported state — no local
|
||||
// order is generated (WS-authoritative for shuffle order).
|
||||
let shuffle_on = remote.shuffle_mode;
|
||||
let repeat_mode = remote.repeat_mode;
|
||||
// Skip the UI hop when nothing the push depends on changed
|
||||
// since the last push (see the dirty-guard comment at the
|
||||
// trackers). While playing, `position_ms` extrapolates every
|
||||
// tick, so pushes proceed; paused, the bar stops repainting.
|
||||
let remote_snapshot = (
|
||||
remote.track_id,
|
||||
position_ms,
|
||||
duration_secs,
|
||||
playing,
|
||||
remote_volume.to_bits(),
|
||||
shuffle_on,
|
||||
repeat_mode,
|
||||
);
|
||||
if last_remote_ui_push != Some(remote_snapshot) {
|
||||
last_remote_ui_push = Some(remote_snapshot);
|
||||
// Keep the visualizer producer in step with the same flag
|
||||
// the drain gate reads (a paused PEER parks it too;
|
||||
// playback.rs:5205-5209).
|
||||
crate::viz_qt::set_paused(!playing);
|
||||
// The peer branch `continue`s past the local body that
|
||||
// re-sets the seek lock every tick — force it open here
|
||||
// (playback.rs:5219) or a mid-stream takeover would latch
|
||||
// the last buffer fraction and refuse routed seeks.
|
||||
crate::now_playing::set_seekable_max(1.0);
|
||||
// `has_audio = true` clears the fetch spinner: a peer owns
|
||||
// audio, so there is no local fetch wait and the bar's
|
||||
// spinner must never linger here (the Slint force-clears
|
||||
// PENDING_PLAY_ID on the peer edge, playback.rs:5228-5233
|
||||
// — Qt has no PENDING_PLAY_ID/clear_loading; this push is
|
||||
// guaranteed on the edge because track_id is in the
|
||||
// dirty-guard tuple). Cache overlay 0.0 = "not streaming",
|
||||
// same value the cast poll publishes (cast_qt.rs:1000).
|
||||
crate::now_playing::set_position(
|
||||
position_secs as i32,
|
||||
duration_secs as i32,
|
||||
playing,
|
||||
0.0,
|
||||
true,
|
||||
);
|
||||
crate::now_playing::set_volume(remote_volume);
|
||||
crate::now_playing::set_shuffle(shuffle_on);
|
||||
crate::now_playing::set_repeat_mode(repeat_mode);
|
||||
}
|
||||
// Reset the LOCAL edge trackers so when control returns to QBZ
|
||||
// the end-of-track / gapless / transition logic re-detects
|
||||
// from a clean slate (the local player was stopped while
|
||||
// peer-active).
|
||||
last_track_id = 0;
|
||||
was_playing = false;
|
||||
seen_position = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// CAST: the cast service runs its own 1 s poll and the local player is
|
||||
// stopped, so this path would push position 0 / not-playing every
|
||||
// second and fight it.
|
||||
@@ -1517,6 +2010,13 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
last_track_id = 0;
|
||||
was_playing = false;
|
||||
seen_position = 0;
|
||||
// Same invariant for the PEER edge trackers (playback.rs:5268-5269):
|
||||
// the remote branch above did not run, so without this reset a
|
||||
// re-taken peer whose snapshot happens to match the last controller
|
||||
// push would be skipped, leaving the cast renderer's values on the
|
||||
// bar (mirrors the fall-through reset below).
|
||||
last_peer_track_id = 0;
|
||||
last_remote_ui_push = None;
|
||||
// A cast target owns playback and there is no local download to
|
||||
// outrun: seek is unlocked while casting, the way the Slint cast
|
||||
// publish forces it (`cast_service.rs:1118`). Without this the last
|
||||
@@ -1524,6 +2024,16 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
crate::now_playing::set_seekable_max(1.0);
|
||||
continue;
|
||||
}
|
||||
// Not in controller mode (no peer / returned to local): reset the
|
||||
// peer-track edge var so re-entering the peer state refreshes meta
|
||||
// (playback.rs:5274-5277).
|
||||
last_peer_track_id = 0;
|
||||
last_remote_ui_push = None;
|
||||
// §11.1: deliberately NO lyrics anchor clear here (the Slint clears
|
||||
// per-tick at playback.rs:5279) — the Qt anchor clears from the single
|
||||
// `now_playing::set_remote(false)` choke point, which unlike this
|
||||
// fallthrough also runs while the cast branch owns the loop. See the
|
||||
// note in `now_playing::set_remote`.
|
||||
let event = runtime.core().player().get_playback_event();
|
||||
let track_id = event.track_id;
|
||||
let position = event.position;
|
||||
@@ -1714,6 +2224,47 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- QConnect: outbound renderer state report -----------------
|
||||
// (contract §6.2 — Slint playback.rs:5676-5710, placed after the
|
||||
// position push / before the end-of-track edge exactly like the
|
||||
// reference.) When QBZ is the ACTIVE LOCAL renderer (controlled by
|
||||
// a remote controller like the iOS app), report our playback state
|
||||
// so the controller's seek bar + current-track follow. Mirrors the
|
||||
// official client: position/duration in MILLISECONDS (the Qt poll
|
||||
// position is SECONDS — ×1000 at the call), ~2s periodic while
|
||||
// playing + an immediate report on every transition. The service
|
||||
// self-gates on is_local_renderer_active (no-op when we're a peer
|
||||
// controller or not connected) and resolves the queue_item_ids —
|
||||
// no extra gates.
|
||||
report_tick = report_tick.wrapping_add(1);
|
||||
if track_id != 0 {
|
||||
let transition =
|
||||
track_id != last_reported_track_id || is_playing != last_reported_playing;
|
||||
let periodic = is_playing && report_tick % QCONNECT_REPORT_EVERY_N_TICKS == 0;
|
||||
if transition || periodic {
|
||||
if let Some(svc) = crate::qconnect_qt::service() {
|
||||
let playing_state = if is_playing {
|
||||
qconnect_app::renderer::PLAYING_STATE_PLAYING
|
||||
} else {
|
||||
qconnect_app::renderer::PLAYING_STATE_PAUSED
|
||||
};
|
||||
let position_ms = (position as i64) * 1000;
|
||||
let duration_ms = (duration as i64) * 1000;
|
||||
svc.report_playback_state(playing_state, position_ms, duration_ms, track_id)
|
||||
.await;
|
||||
// On a track change, also reconcile the session queue:
|
||||
// if the user started a new album/playlist on QBZ,
|
||||
// push it so the controller follows. Self-gates +
|
||||
// echo-suppresses.
|
||||
if transition {
|
||||
svc.sync_local_queue_if_changed().await;
|
||||
}
|
||||
}
|
||||
last_reported_track_id = track_id;
|
||||
last_reported_playing = is_playing;
|
||||
}
|
||||
}
|
||||
|
||||
// --- End-of-track edge (playback.rs condition, verbatim) ------
|
||||
let track_ended = was_playing
|
||||
&& !is_playing
|
||||
|
||||
@@ -98,6 +98,14 @@ pub mod qbz_player {
|
||||
#[qproperty(bool, np_cast_active)]
|
||||
// "cast" | "dlna".
|
||||
#[qproperty(QString, np_cast_protocol)]
|
||||
// REMOTE volume lock (Qobuz Connect half of Slint
|
||||
// `NowPlayingState.volume-locked`, contract §11.3): the active PEER
|
||||
// renderer disallows remote volume control. Distinct from
|
||||
// `np_volume_locked` (the read-only ALSA-hw settings derivation in
|
||||
// `output_labels.rs`) — the bars' volume-lock expression combines
|
||||
// both. Written by `now_playing::set_remote_volume_locked` from the
|
||||
// qconnect sink's badge refresh + the facade's disconnect tail.
|
||||
#[qproperty(bool, np_remote_volume_locked)]
|
||||
// Now-playing track id (playing-row indicator in track lists).
|
||||
#[qproperty(QString, np_track_id)]
|
||||
#[qproperty(QString, np_album)]
|
||||
@@ -194,7 +202,9 @@ pub mod qbz_player {
|
||||
impl cxx_qt::Threading for QbzPlayer {}
|
||||
}
|
||||
|
||||
use qbz_player::QbzPlayer;
|
||||
// `self::` disambiguates the bridge mod from the `qbz-player` extern crate
|
||||
// (added for the QConnect engine, contract §1.1) — E0659 otherwise.
|
||||
use self::qbz_player::QbzPlayer;
|
||||
|
||||
/// Rust side of the player bridge (plain storage, phase-1 pattern).
|
||||
pub struct QbzPlayerRust {
|
||||
@@ -229,6 +239,7 @@ pub struct QbzPlayerRust {
|
||||
np_cast_target: QString,
|
||||
np_cast_active: bool,
|
||||
np_cast_protocol: QString,
|
||||
np_remote_volume_locked: bool,
|
||||
np_track_id: QString,
|
||||
np_album: QString,
|
||||
np_album_id: QString,
|
||||
@@ -280,6 +291,7 @@ impl Default for QbzPlayerRust {
|
||||
np_cast_target: QString::default(),
|
||||
np_cast_active: false,
|
||||
np_cast_protocol: QString::default(),
|
||||
np_remote_volume_locked: false,
|
||||
np_track_id: QString::default(),
|
||||
np_album: QString::default(),
|
||||
np_album_id: QString::default(),
|
||||
|
||||
@@ -1504,6 +1504,13 @@ pub async fn enqueue_playlist_by_id(
|
||||
queue_for(runtime, playlist_id).await?,
|
||||
crate::playback_qt::PlayContext::playlist(&playlist_id.to_string()),
|
||||
);
|
||||
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
|
||||
// queue — early-returns when handled, so the local insert + sync tail
|
||||
// below only run in local/renderer mode.
|
||||
if crate::playback_qt::route_enqueue_to_peer(&tracks, mode).await {
|
||||
return Ok(());
|
||||
}
|
||||
let added_castable = crate::playback_qt::batch_all_qconnect_castable(&tracks);
|
||||
match mode {
|
||||
"next" => {
|
||||
for track in tracks.into_iter().rev() {
|
||||
@@ -1517,6 +1524,9 @@ pub async fn enqueue_playlist_by_id(
|
||||
}
|
||||
_ => runtime.core().add_tracks(tracks).await,
|
||||
}
|
||||
// QConnect sync-on-add (#442): push the updated queue to the session;
|
||||
// skipped silently for a non-castable batch.
|
||||
crate::playback_qt::sync_qconnect_after_add(added_castable).await;
|
||||
crate::queue_qt::publish(runtime).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1632,6 +1642,11 @@ async fn play_queue_at(
|
||||
let first_id = tracks[start].id;
|
||||
crate::playback_qt::set_queue_stamped(runtime, tracks, Some(start), context).await;
|
||||
crate::queue_qt::publish(runtime).await;
|
||||
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
|
||||
// funnel, before the local audible step).
|
||||
if crate::playback_qt::route_play_to_peer(runtime, first_id).await {
|
||||
return Ok(());
|
||||
}
|
||||
runtime
|
||||
.core()
|
||||
.play_track_resolved(first_id, crate::playback_qt::current_quality(), None, None, 0)
|
||||
@@ -1675,11 +1690,21 @@ pub async fn enqueue_track(
|
||||
let qt = crate::playback_qt::stamped(vec![row_to_queue(&row)], open_context())
|
||||
.pop()
|
||||
.expect("one row in, one row out");
|
||||
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
|
||||
// queue — early-returns when handled, so the local insert + sync tail
|
||||
// below only run in local/renderer mode.
|
||||
if crate::playback_qt::route_track_to_peer(&qt, mode).await {
|
||||
return Ok(());
|
||||
}
|
||||
let added_castable = crate::playback_qt::batch_all_qconnect_castable(std::slice::from_ref(&qt));
|
||||
match mode {
|
||||
"next" => runtime.core().add_track_next(qt).await,
|
||||
"later" => runtime.core().add_track_later(qt).await,
|
||||
_ => runtime.core().add_track(qt).await,
|
||||
}
|
||||
// QConnect sync-on-add (#442): push the updated queue to the session;
|
||||
// skipped silently for a non-castable add.
|
||||
crate::playback_qt::sync_qconnect_after_add(added_castable).await;
|
||||
crate::queue_qt::publish(runtime).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! QbzQConnect — Qobuz Connect domain bridge (block B4-Rust of the
|
||||
//! 2026-08-01 QConnect Qt-port contract, §8). The QML surface of the Slint
|
||||
//! `NowPlayingState.qconnect-connected` toggle + the `QconnectDevState`
|
||||
//! global (device picker rows, active renderer, diagnostics modal); the
|
||||
//! service behind it is the facade/sink in `qconnect_qt.rs` /
|
||||
//! `qconnect_event_sink_qt.rs` over the shared `qconnect-app` crate.
|
||||
//!
|
||||
//! Pattern, verbatim from `src/queue_bridge.rs`: ONE `#[cxx_qt::bridge]`
|
||||
//! module, a `#[qml_element] #[qml_singleton]` QObject, its own
|
||||
//! `OnceLock<CxxQtThread>`, a `boot()` invokable that registers the
|
||||
//! Qt-thread hop and a `pub(crate) fn ui()` that queues mutations onto it.
|
||||
//! The invokables are one-line forwards into `qconnect_qt.rs`; async work
|
||||
//! goes through `crate::spawn`.
|
||||
//!
|
||||
//! DEVICES_JSON SHAPE (what `QconnectFlyout.qml` / `QconnectDevModal.qml`
|
||||
//! `JSON.parse()`): a JSON array of objects with EXACTLY these keys —
|
||||
//! { renderer_id: int, name: string, is_local: bool, is_active: bool,
|
||||
//! icon: "mobile" | "web" | "computer" | "speaker" }
|
||||
//! One JSON document rather than a QVariantList of QVariantMaps for the
|
||||
//! reason `cast_bridge.rs`'s own `devices_json` gives (cxx-qt-lib 0.7.3
|
||||
//! has no `QVariantValue` impl for `QMap`/`QList`). Rebuilt and pushed by
|
||||
//! the sink on every inbound event (push-driven — QML never polls).
|
||||
//!
|
||||
//! The 8 s "Looking for devices…" discovery window and the silent connect
|
||||
//! timeout stay QML-side timers in the flyout (1:1 with the Slint
|
||||
//! `qc-connecting` flag + timer) — there is deliberately NO Rust busy
|
||||
//! flag on this bridge (contract §8 state-mapping table).
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use cxx_qt::CxxQtThread;
|
||||
use cxx_qt::Threading as _;
|
||||
use cxx_qt_lib::QString;
|
||||
|
||||
#[cxx_qt::bridge]
|
||||
pub mod qbz_qconnect {
|
||||
extern "C++" {
|
||||
include!("cxx-qt-lib/qstring.h");
|
||||
type QString = cxx_qt_lib::QString;
|
||||
}
|
||||
|
||||
#[auto_cxx_name]
|
||||
extern "RustQt" {
|
||||
#[qobject]
|
||||
#[qml_element]
|
||||
#[qml_singleton]
|
||||
// --- Bar badge ---------------------------------------------------
|
||||
// The golden ConnectButton state (`NowPlayingState.qconnect-connected`).
|
||||
// Flipped by the manual toggle's success tail, the startup
|
||||
// auto-connect success tail and the offline force-disconnect watcher —
|
||||
// the facade itself never writes it (1:1 with the reference).
|
||||
#[qproperty(bool, qconnect_connected)]
|
||||
|
||||
// --- Device picker -------------------------------------------------
|
||||
// JSON array of device rows (see the module header for the exact
|
||||
// shape). Rebuilt on every inbound event by the sink.
|
||||
#[qproperty(QString, devices_json)]
|
||||
// -1 = none.
|
||||
#[qproperty(i32, active_renderer_id)]
|
||||
|
||||
// --- Diagnostics modal (DeveloperSettings > QOBUZ CONNECT) ---------
|
||||
// QML-driven open/close (DeveloperSettings sets it true; the modal's
|
||||
// own close sets it false).
|
||||
#[qproperty(bool, diag_open)]
|
||||
// The live status block (session topology / renderer roles / queue).
|
||||
#[qproperty(QString, diag_status)]
|
||||
// The rolling 150-line event log, newest first.
|
||||
#[qproperty(QString, diag_log_text)]
|
||||
|
||||
type QbzQConnect = super::QbzQConnectRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
|
||||
/// domain singleton; only QbzSession.boot also fires crate::on_boot).
|
||||
#[qinvokable]
|
||||
fn boot(self: Pin<&mut QbzQConnect>);
|
||||
|
||||
/// The bar/flyout connect toggle: connects when off, disconnects when
|
||||
/// on. Flips `qconnect_connected` from its success tail and does the
|
||||
/// RememberLast write-through (see the impl for the 1:1 semantics).
|
||||
#[qinvokable]
|
||||
fn connect_toggle(self: Pin<&mut QbzQConnect>);
|
||||
|
||||
/// Picker row: make `renderer_id` the session's active renderer.
|
||||
#[qinvokable]
|
||||
fn set_active(self: Pin<&mut QbzQConnect>, renderer_id: i32);
|
||||
|
||||
/// Diagnostics modal footer: clear the rolling event log.
|
||||
#[qinvokable]
|
||||
fn diag_clear(self: Pin<&mut QbzQConnect>);
|
||||
|
||||
/// Diagnostics modal visibility (QML owns both directions). NOT named
|
||||
/// `set_diag_open`: the qproperty codegen already emits that setter,
|
||||
/// so the invokable would redefine it (same class of collision
|
||||
/// cast_bridge.rs documents for `connect`/`disconnect`). QML may also
|
||||
/// assign the `diagOpen` property directly.
|
||||
#[qinvokable]
|
||||
fn diag_set_open(self: Pin<&mut QbzQConnect>, open: bool);
|
||||
}
|
||||
|
||||
impl cxx_qt::Threading for QbzQConnect {}
|
||||
}
|
||||
|
||||
use qbz_qconnect::QbzQConnect;
|
||||
|
||||
/// Rust side of the QConnect bridge (plain storage, phase-1 pattern).
|
||||
pub struct QbzQConnectRust {
|
||||
qconnect_connected: bool,
|
||||
devices_json: QString,
|
||||
active_renderer_id: i32,
|
||||
diag_open: bool,
|
||||
diag_status: QString,
|
||||
diag_log_text: QString,
|
||||
}
|
||||
|
||||
impl Default for QbzQConnectRust {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
qconnect_connected: false,
|
||||
// "[]" so the picker's JSON.parse never throws on the first
|
||||
// frame (the home_bridge / cast_bridge convention).
|
||||
devices_json: QString::from("[]"),
|
||||
active_renderer_id: -1,
|
||||
diag_open: false,
|
||||
diag_status: QString::default(),
|
||||
diag_log_text: QString::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The UI hop (bridges/mod.rs pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static QT_THREAD: OnceLock<CxxQtThread<QbzQConnect>> = OnceLock::new();
|
||||
|
||||
/// Queue a QConnect-bridge mutation onto the Qt event loop (no-op before
|
||||
/// boot registers the thread).
|
||||
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzQConnect>) + Send + 'static) {
|
||||
if let Some(thread) = QT_THREAD.get() {
|
||||
let _ = thread.queue(f);
|
||||
}
|
||||
}
|
||||
|
||||
impl qbz_qconnect::QbzQConnect {
|
||||
pub fn boot(self: Pin<&mut Self>) {
|
||||
if QT_THREAD.set(self.qt_thread()).is_err() {
|
||||
log::warn!("[qbz-qt] qconnect Qt thread already registered");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connect_toggle(self: Pin<&mut Self>) {
|
||||
let Some(service) = crate::qconnect_qt::service() else {
|
||||
return;
|
||||
};
|
||||
crate::spawn(async move {
|
||||
// 1:1 with the Slint `on_qconnect_toggle` (reference
|
||||
// main.rs:14043-14087). `record` is the RememberLast write-through
|
||||
// value — Some ONLY when the operation succeeded, so a failed
|
||||
// manual connect never downgrades a remembered "connected".
|
||||
let (connected, record) = if service.is_running().await {
|
||||
if let Err(err) = service.disconnect().await {
|
||||
log::warn!("[QConnect] disconnect failed: {err}");
|
||||
}
|
||||
(false, Some(false))
|
||||
} else {
|
||||
match service.connect().await {
|
||||
Ok(()) => (true, Some(true)),
|
||||
Err(err) => {
|
||||
log::warn!("[QConnect] connect failed: {err}");
|
||||
// The offline refusal already toasted from INSIDE
|
||||
// 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.
|
||||
if err != "Qobuz Connect is unavailable while offline" {
|
||||
crate::toast_qt::error(err);
|
||||
}
|
||||
(false, None)
|
||||
}
|
||||
}
|
||||
};
|
||||
// Record the USER-chosen on/off here — the authoritative intent
|
||||
// point (the bar toggle is the only manual path) — so a crash
|
||||
// can't lose it and internal teardowns (offline force-disconnect,
|
||||
// bootstrap cleanup) never overwrite it. Only while mode ==
|
||||
// RememberLast, mirroring the reference. Blocking SQLite, so
|
||||
// spawn_blocking exactly like the reference does.
|
||||
if let Some(state) = record {
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
if crate::qconnect_transport_qt::load_startup_mode()
|
||||
== qconnect_app::QconnectStartupMode::RememberLast
|
||||
{
|
||||
crate::qconnect_transport_qt::save_last_known_state(state);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
// THE MANUAL-TOGGLE TAIL: the facade deliberately does NOT flip
|
||||
// the badge itself (1:1 with the Slint reference) — the toggle,
|
||||
// the startup auto-connect success tail and the offline
|
||||
// force-disconnect watcher each publish their own flip.
|
||||
crate::qconnect_qt::publish::connected(connected);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_active(self: Pin<&mut Self>, renderer_id: i32) {
|
||||
let Some(service) = crate::qconnect_qt::service() else {
|
||||
// Contract §9 D3: hardcoded English plain &str, NO msgid (1:1
|
||||
// with the reference's `main.rs:14201`).
|
||||
crate::toast_qt::error("Failed to switch renderer");
|
||||
return;
|
||||
};
|
||||
crate::spawn(async move {
|
||||
if let Err(err) = service.set_active_renderer(renderer_id).await {
|
||||
log::warn!("[QConnect] set_active_renderer({renderer_id}) failed: {err}");
|
||||
crate::toast_qt::error("Failed to switch renderer");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn diag_clear(self: Pin<&mut Self>) {
|
||||
crate::qconnect_qt::dev_clear();
|
||||
}
|
||||
|
||||
pub fn diag_set_open(mut self: Pin<&mut Self>, open: bool) {
|
||||
self.as_mut().set_diag_open(open);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
//! Qobuz Connect renderer engine for the Qt frontend (block B2 of the
|
||||
//! 2026-08-01 QConnect Qt-port contract).
|
||||
//!
|
||||
//! Behavior-1:1 port of the Slint `qbz/src/qconnect_engine.rs`. Implements
|
||||
//! [`qconnect_app::QconnectRendererEngine`] over the Qt `AppRuntime`'s `QbzCore`
|
||||
//! + `Player`, so qbz-qt becomes a QConnect renderer that inherits the shared
|
||||
//! echo/cursor/materialize/shuffle orchestration in `qconnect_app::renderer`
|
||||
//! instead of re-deriving it.
|
||||
//!
|
||||
//! The protected bit-perfect seams (`play_streaming_dynamic` / `play_data`) and
|
||||
//! the HTTP feeder live here, impl-side, exactly as the Tauri `CoreBridge` impl
|
||||
//! does; the probe-derived sample_rate/channels/bit_depth flow STRAIGHT into
|
||||
//! `play_streaming_dynamic` (never defaulted, or hi-res remote playback silently
|
||||
//! resamples). The feeder body is a near-verbatim port of the Tauri
|
||||
//! `track_loading.rs` feeder, with `bridge.player()` -> `self.core().player()`;
|
||||
//! the only deviation is the TLS backend — the crates workspace `reqwest` ships
|
||||
//! `rustls-tls` (not `native-tls`), so the `.use_native_tls()` calls are dropped.
|
||||
//! TLS is transport encryption only; the decoded audio bytes are identical, so
|
||||
//! bit-perfect is unaffected. (If the Qobuz streaming CDN ever presents a cert
|
||||
//! rustls rejects, add `native-tls` to the frontend crate's reqwest features.)
|
||||
//!
|
||||
//! The feeder helpers (HEAD+range FLAC probe, chunked GET -> `BufferWriter`,
|
||||
//! Akamai >100-header detection) are PRIVATE copies of the Slint crate's
|
||||
//! `remote_stream.rs`, folded into this file per contract §1.3 — deliberately
|
||||
//! 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)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::{LoggingAdapter, QbzCore};
|
||||
use qbz_models::{Quality, QueueTrack, RepeatMode, Track};
|
||||
use qbz_player::{BufferWriter, PlaybackState, Player};
|
||||
use qconnect_app::QconnectRendererEngine;
|
||||
|
||||
/// QConnect renderer engine backed by the Qt `AppRuntime`. Holds the shared
|
||||
/// runtime and forwards every trait method through `runtime.core()`; the async
|
||||
/// feeder spawns on the ambient tokio runtime (`start_track_stream` is always
|
||||
/// awaited from a runtime task).
|
||||
pub struct QtRendererEngine {
|
||||
runtime: Arc<AppRuntime<LoggingAdapter>>,
|
||||
}
|
||||
|
||||
impl QtRendererEngine {
|
||||
pub fn new(runtime: Arc<AppRuntime<LoggingAdapter>>) -> Self {
|
||||
Self { runtime }
|
||||
}
|
||||
|
||||
fn core(&self) -> &Arc<QbzCore<LoggingAdapter>> {
|
||||
self.runtime.core()
|
||||
}
|
||||
|
||||
/// Last-resort load for tracks the raw-URL path cannot fetch (the CDN
|
||||
/// header flood defeats every reqwest attempt — see
|
||||
/// `is_header_flood_error`): the CMAF path is unaffected by the h1 header
|
||||
/// cap. `play_track_resolved` does NOT move the queue cursor (nothing on
|
||||
/// the QConnect path does — the shared driver's cursor sync only fires on a
|
||||
/// playing->playing track edge), so sync it explicitly or the now-playing
|
||||
/// truth keeps showing the PREVIOUS track while the recovered one plays.
|
||||
async fn play_via_cmaf(
|
||||
&self,
|
||||
track_id: u64,
|
||||
quality: Quality,
|
||||
start_position_secs: u64,
|
||||
) -> Result<(), String> {
|
||||
self.core()
|
||||
.play_track_resolved(track_id, quality, None, None, start_position_secs)
|
||||
.await
|
||||
.map_err(|err| format!("CMAF fallback for remote track {track_id}: {err}"))?;
|
||||
self.core().sync_current_to_id(track_id).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl QconnectRendererEngine for QtRendererEngine {
|
||||
// ---- transport (sync) ----
|
||||
fn resume(&self) -> Result<(), String> {
|
||||
self.core().resume().map_err(|err| err.to_string())
|
||||
}
|
||||
fn pause(&self) -> Result<(), String> {
|
||||
self.core().pause().map_err(|err| err.to_string())
|
||||
}
|
||||
fn stop(&self) -> Result<(), String> {
|
||||
self.core().stop().map_err(|err| err.to_string())
|
||||
}
|
||||
fn seek(&self, position_secs: u64) -> Result<(), String> {
|
||||
self.core().seek(position_secs).map_err(|err| err.to_string())
|
||||
}
|
||||
fn set_volume(&self, fraction: f32) -> Result<(), String> {
|
||||
self.core().set_volume(fraction).map_err(|err| err.to_string())
|
||||
}
|
||||
fn get_playback_state(&self) -> PlaybackState {
|
||||
self.core().get_playback_state()
|
||||
}
|
||||
fn has_loaded_audio(&self) -> bool {
|
||||
self.core().player().has_loaded_audio()
|
||||
}
|
||||
|
||||
// ---- queue / mode (async) ----
|
||||
async fn set_repeat_mode(&self, mode: RepeatMode) {
|
||||
self.core().set_repeat_mode(mode).await
|
||||
}
|
||||
async fn set_shuffle(&self, enabled: bool) {
|
||||
self.core().set_shuffle(enabled).await
|
||||
}
|
||||
async fn set_shuffle_flag(&self, enabled: bool) {
|
||||
self.core().set_shuffle_with_order(enabled, None).await
|
||||
}
|
||||
async fn get_all_queue_tracks(&self) -> (Vec<QueueTrack>, Option<usize>) {
|
||||
self.core().get_all_queue_tracks().await
|
||||
}
|
||||
async fn set_queue(&self, tracks: Vec<QueueTrack>, start_index: Option<usize>) {
|
||||
self.core().set_queue(tracks, start_index).await
|
||||
}
|
||||
async fn set_queue_with_order(
|
||||
&self,
|
||||
tracks: Vec<QueueTrack>,
|
||||
start_index: Option<usize>,
|
||||
shuffle_enabled: bool,
|
||||
shuffle_order: Option<Vec<usize>>,
|
||||
) {
|
||||
self.core()
|
||||
.set_queue_with_order(tracks, start_index, shuffle_enabled, shuffle_order)
|
||||
.await
|
||||
}
|
||||
async fn clear_queue(&self, keep_current: bool) {
|
||||
self.core().clear_queue(keep_current).await
|
||||
}
|
||||
async fn play_index(&self, index: usize) -> Option<QueueTrack> {
|
||||
self.core().play_index(index).await
|
||||
}
|
||||
|
||||
// ---- catalog (async) ----
|
||||
async fn get_track(&self, track_id: u64) -> Result<Track, String> {
|
||||
self.core()
|
||||
.get_track(track_id)
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
async fn get_tracks_batch(&self, track_ids: &[u64]) -> Result<Vec<Track>, String> {
|
||||
self.core()
|
||||
.get_tracks_batch(track_ids)
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
// ---- protected audio seam (the only protected touch) ----
|
||||
async fn start_track_stream(
|
||||
&self,
|
||||
track_id: u64,
|
||||
quality: Quality,
|
||||
duration_secs: u64,
|
||||
start_position_secs: u64,
|
||||
) -> Result<(), String> {
|
||||
let stream_url = self
|
||||
.core()
|
||||
.get_stream_url(track_id, quality)
|
||||
.await
|
||||
.map_err(|err| format!("resolve stream url for remote track {track_id}: {err}"))?;
|
||||
|
||||
let player = self.core().player();
|
||||
let stream_result = stream_remote_track_into_player(
|
||||
&player,
|
||||
track_id,
|
||||
duration_secs,
|
||||
start_position_secs,
|
||||
&stream_url.url,
|
||||
"QConnect",
|
||||
)
|
||||
.await;
|
||||
|
||||
let Err(stream_err) = stream_result else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Akamai small-object header flood: SMALL raw-url objects come back
|
||||
// with ~106 headers, over hyper's hard-coded 100-header h1 cap, so
|
||||
// EVERY reqwest fetch of this URL fails — the full download would die
|
||||
// the same death. Skip it and go straight to the CMAF last resort.
|
||||
if is_header_flood_error(&stream_err) {
|
||||
log::warn!(
|
||||
"[QConnect] Raw-URL streaming hit the CDN header flood for track {track_id}: {stream_err}. Skipping full download; last resort: CMAF."
|
||||
);
|
||||
return self
|
||||
.play_via_cmaf(track_id, quality, start_position_secs)
|
||||
.await;
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"[QConnect] Streaming handoff unavailable for track {}: {}. Falling back to full download.",
|
||||
track_id,
|
||||
stream_err
|
||||
);
|
||||
match download_remote_audio(&stream_url.url).await {
|
||||
Ok(audio_data) => {
|
||||
self.core()
|
||||
.player()
|
||||
.play_data(audio_data, track_id)
|
||||
.map_err(|err| format!("play remote track {track_id}: {err}"))?;
|
||||
Ok(())
|
||||
}
|
||||
Err(download_err) if is_header_flood_error(&download_err) => {
|
||||
log::warn!(
|
||||
"[QConnect] Full download hit the CDN header flood for track {track_id}: {download_err}. Last resort: CMAF."
|
||||
);
|
||||
self.play_via_cmaf(track_id, quality, start_position_secs)
|
||||
.await
|
||||
}
|
||||
Err(download_err) => Err(download_err),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_output_format(&self) -> Option<(u32, u32)> {
|
||||
let player = self.core().player();
|
||||
Some((
|
||||
player.state.get_sample_rate(),
|
||||
player.state.get_bit_depth(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async fn download_remote_audio(url: &str) -> Result<Vec<u8>, String> {
|
||||
let response = reqwest::Client::new()
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"download remote audio request failed: {}",
|
||||
describe_reqwest_error(&err)
|
||||
)
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"download remote audio failed with status {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = response.bytes().await.map_err(|err| {
|
||||
format!(
|
||||
"read remote audio bytes failed: {}",
|
||||
describe_reqwest_error(&err)
|
||||
)
|
||||
})?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP feeder helpers — private copies of the Slint crate's `remote_stream.rs`
|
||||
// (contract §1.3; the Slint `qconnect_engine.rs` reaches the same bodies via
|
||||
// `crate::remote_stream::`). Progressive feeder: probe a remote audio URL for
|
||||
// size + FLAC format, open the player's progressive streaming sink
|
||||
// (`Player::play_streaming_dynamic`), then push the body to the returned
|
||||
// `BufferWriter` chunk-by-chunk as it arrives. Playback starts as soon as the
|
||||
// initial buffer fills — not after the whole file lands.
|
||||
//
|
||||
// BIT-PERFECT: `play_streaming_dynamic` decodes the same original bytes and
|
||||
// drives the PROTECTED device init from the decoded stream. The
|
||||
// `sample_rate`/`bit_depth` parsed here are only the streaming-config hints;
|
||||
// the audio backend (`pipewire_backend.rs`, `init_device`, `audio_settings.rs`)
|
||||
// is untouched.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format/size facts sniffed from a remote audio URL before streaming.
|
||||
struct RemoteStreamInfo {
|
||||
content_length: u64,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
bit_depth: u32,
|
||||
speed_mbps: f64,
|
||||
}
|
||||
|
||||
/// Probe + open the progressive sink + spawn the background feeder.
|
||||
///
|
||||
/// On success the player has begun buffering and `play_streaming_dynamic` will
|
||||
/// start audio once the initial buffer fills; the body download runs in a
|
||||
/// spawned task. Errors here mean the caller should fall back to a full
|
||||
/// download (the probe or the sink open failed).
|
||||
async fn stream_remote_track_into_player(
|
||||
player: &Player,
|
||||
track_id: u64,
|
||||
duration_secs: u64,
|
||||
start_position_secs: u64,
|
||||
url: &str,
|
||||
log_tag: &str,
|
||||
) -> Result<(), String> {
|
||||
let stream_info = probe_remote_stream_info(url).await?;
|
||||
log::info!(
|
||||
"[{}/STREAMING] Track {} - {:.2} MB, {}Hz, {} ch, {}-bit, {:.1} MB/s",
|
||||
log_tag,
|
||||
track_id,
|
||||
stream_info.content_length as f64 / (1024.0 * 1024.0),
|
||||
stream_info.sample_rate,
|
||||
stream_info.channels,
|
||||
stream_info.bit_depth,
|
||||
stream_info.speed_mbps
|
||||
);
|
||||
|
||||
let writer = player
|
||||
.play_streaming_dynamic(
|
||||
track_id,
|
||||
stream_info.sample_rate,
|
||||
stream_info.channels,
|
||||
stream_info.bit_depth,
|
||||
stream_info.content_length,
|
||||
stream_info.speed_mbps,
|
||||
duration_secs,
|
||||
start_position_secs,
|
||||
)
|
||||
.map_err(|err| format!("start streaming remote track {track_id}: {err}"))?;
|
||||
|
||||
let url = url.to_string();
|
||||
let content_length = stream_info.content_length;
|
||||
let log_tag = log_tag.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) =
|
||||
download_and_stream_remote_track(&url, writer, track_id, content_length, &log_tag).await
|
||||
{
|
||||
log::error!(
|
||||
"[{}/STREAMING] Track {} failed while streaming: {}",
|
||||
log_tag,
|
||||
track_id,
|
||||
err
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// HEAD for content-length, then a small `Range: bytes=0-65535` GET to (a)
|
||||
/// measure throughput and (b) parse the FLAC `STREAMINFO` block for the real
|
||||
/// sample rate / channels / bit depth. Never defaults silently for FLAC (a
|
||||
/// wrong sample rate would silently resample hi-res).
|
||||
async fn probe_remote_stream_info(url: &str) -> Result<RemoteStreamInfo, String> {
|
||||
use std::time::Instant;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|err| format!("create stream probe client: {err}"))?;
|
||||
|
||||
let head_response = client
|
||||
.head(url)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("probe HEAD request failed: {}", describe_reqwest_error(&err)))?;
|
||||
|
||||
if !head_response.status().is_success() {
|
||||
return Err(format!(
|
||||
"probe HEAD request failed with status {}",
|
||||
head_response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let content_length = head_response
|
||||
.headers()
|
||||
.get("content-length")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.ok_or_else(|| "probe missing content-length header".to_string())?;
|
||||
|
||||
let start_time = Instant::now();
|
||||
let range_response = client
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.header("Range", "bytes=0-65535")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("probe range request failed: {}", describe_reqwest_error(&err)))?;
|
||||
|
||||
if !range_response.status().is_success() {
|
||||
return Err(format!(
|
||||
"probe range request failed with status {}",
|
||||
range_response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let initial_bytes = range_response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| format!("read probe bytes failed: {}", describe_reqwest_error(&err)))?;
|
||||
|
||||
let elapsed = start_time.elapsed();
|
||||
let speed_mbps = if elapsed.as_secs_f64() > 0.0 {
|
||||
(initial_bytes.len() as f64 / elapsed.as_secs_f64()) / (1024.0 * 1024.0)
|
||||
} else {
|
||||
10.0
|
||||
};
|
||||
|
||||
// STREAMINFO parse via the shared prober (hoisted to qbz-models for the
|
||||
// cast path, #638 fix 1). The prober never guesses — the CD-shaped
|
||||
// defaults for a non-FLAC probe stay HERE so this path's behavior is
|
||||
// byte-identical to the original inline parse.
|
||||
let (sample_rate, channels, bit_depth) = match qbz_models::probe_streaminfo(&initial_bytes) {
|
||||
Some(p) => (p.sample_rate, p.channels, p.bits_per_sample),
|
||||
None => {
|
||||
log::warn!("[remote-stream] Non-FLAC probe for remote handoff, using defaults");
|
||||
(44_100, 2, 16)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(RemoteStreamInfo {
|
||||
content_length,
|
||||
sample_rate,
|
||||
channels,
|
||||
bit_depth,
|
||||
speed_mbps,
|
||||
})
|
||||
}
|
||||
|
||||
/// Plain full-body GET → `bytes_stream()` loop → `writer.push_chunk` →
|
||||
/// `writer.complete()`. No HTTP Range on the main GET (the `BufferedMediaSource`
|
||||
/// buffers every pushed byte and serves seeks from the growing buffer).
|
||||
async fn download_and_stream_remote_track(
|
||||
url: &str,
|
||||
writer: BufferWriter,
|
||||
track_id: u64,
|
||||
content_length: u64,
|
||||
log_tag: &str,
|
||||
) -> Result<(), String> {
|
||||
use futures_util::StreamExt;
|
||||
use std::time::Instant;
|
||||
|
||||
struct FailGuard {
|
||||
writer: BufferWriter,
|
||||
armed: bool,
|
||||
}
|
||||
impl Drop for FailGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
let _ = self
|
||||
.writer
|
||||
.error("remote stream aborted before completion".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut guard = FailGuard {
|
||||
writer,
|
||||
armed: true,
|
||||
};
|
||||
let writer = &guard.writer;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()
|
||||
.map_err(|err| format!("create remote streaming client: {err}"))?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"start remote streaming request failed: {}",
|
||||
describe_reqwest_error(&err)
|
||||
)
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"remote streaming request failed with status {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let mut bytes_received = 0u64;
|
||||
let mut stream = response.bytes_stream();
|
||||
let start_time = Instant::now();
|
||||
let mut last_log_time = Instant::now();
|
||||
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result
|
||||
.map_err(|err| format!("remote streaming chunk failed: {}", describe_reqwest_error(&err)))?;
|
||||
bytes_received += chunk.len() as u64;
|
||||
|
||||
if let Err(err) = writer.push_chunk(&chunk) {
|
||||
log::error!(
|
||||
"[{}/STREAMING] Failed to push chunk for track {}: {}",
|
||||
log_tag,
|
||||
track_id,
|
||||
err
|
||||
);
|
||||
guard.armed = false;
|
||||
let _ = writer.error(format!("push_chunk failed: {err}"));
|
||||
return Err(format!("push_chunk failed: {err}"));
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
if now.duration_since(last_log_time) >= Duration::from_secs(2) && content_length > 0 {
|
||||
let progress = (bytes_received as f64 / content_length as f64) * 100.0;
|
||||
let avg_speed =
|
||||
(bytes_received as f64 / start_time.elapsed().as_secs_f64()) / (1024.0 * 1024.0);
|
||||
log::info!(
|
||||
"[{}/STREAMING] Track {} {:.1}% ({:.2}/{:.2} MB) @ {:.2} MB/s",
|
||||
log_tag,
|
||||
track_id,
|
||||
progress,
|
||||
bytes_received as f64 / (1024.0 * 1024.0),
|
||||
content_length as f64 / (1024.0 * 1024.0),
|
||||
avg_speed
|
||||
);
|
||||
last_log_time = now;
|
||||
}
|
||||
}
|
||||
|
||||
guard.armed = false;
|
||||
if let Err(err) = writer.complete() {
|
||||
log::error!(
|
||||
"[{}/STREAMING] Failed to mark stream complete for track {}: {}",
|
||||
log_tag,
|
||||
track_id,
|
||||
err
|
||||
);
|
||||
let _ = writer.error(format!("complete failed: {err}"));
|
||||
return Err(format!("complete failed: {err}"));
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}/STREAMING] Track {} complete: {:.2} MB in {:.1}s",
|
||||
log_tag,
|
||||
track_id,
|
||||
bytes_received as f64 / (1024.0 * 1024.0),
|
||||
start_time.elapsed().as_secs_f64()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// reqwest's `Display` hides the source chain — which is exactly where the
|
||||
/// diagnosis lives (Akamai's >100-header small-object flood surfaces as hyper's
|
||||
/// "message head is too large" two levels down). Walk `source()` and join the
|
||||
/// chain so logs AND signature matching see the real cause.
|
||||
fn describe_reqwest_error(err: &reqwest::Error) -> String {
|
||||
use std::error::Error as _;
|
||||
let mut out = err.to_string();
|
||||
let mut source = err.source();
|
||||
while let Some(cause) = source {
|
||||
out.push_str(": ");
|
||||
out.push_str(&cause.to_string());
|
||||
source = cause.source();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True when an error message (already chain-expanded by
|
||||
/// [`describe_reqwest_error`]) shows hyper's hard-coded h1 100-header cap.
|
||||
/// Akamai answers SMALL raw-url objects with ~106 headers (the `X-AK-GRN` /
|
||||
/// `X-AK-FWD-ERROR: ERR_POC_FWD_OBJ_TOO_SMALL` flood), so EVERY reqwest fetch
|
||||
/// of such an URL fails this way — streaming probe and full download alike.
|
||||
fn is_header_flood_error(message: &str) -> bool {
|
||||
let haystack = message.to_ascii_lowercase();
|
||||
haystack.contains("message head is too large") || haystack.contains("too many headers")
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
//! Qt `QconnectEventSink` (block B3/B4 of the 2026-08-01 QConnect Qt-port
|
||||
//! contract) — the inbound event dispatch.
|
||||
//!
|
||||
//! Behavior-1:1 port of the Slint `qbz/src/qconnect_event_sink.rs` (which itself
|
||||
//! mirrors the Tauri `src-tauri/src/qconnect/event_sink.rs` arm-for-arm).
|
||||
//! Receives `QconnectAppEvent`s from the qconnect-app crate and dispatches them
|
||||
//! into the `QtRendererEngine` (the renderer seam), the shared
|
||||
//! `QconnectRemoteSyncState` accumulator, and the Qt UI.
|
||||
//!
|
||||
//! UI surfacing per the contract: every Slint UI push becomes either a real
|
||||
//! publish wired NOW — toasts via `crate::toast_qt` (msgids per contract §10),
|
||||
//! is-remote/cast-target via `crate::now_playing::set_remote` (the reserved
|
||||
//! seam, gated on `transport_connected` per the stale-badge fix), remote
|
||||
//! volume-locked via `crate::now_playing::set_remote_volume_locked`, the queue
|
||||
//! UI refresh via `crate::queue_qt::publish(runtime)`, now-playing meta /
|
||||
//! shuffle-repeat via the same refresh functions `playback_qt.rs` uses — or
|
||||
//! the `qconnect_qt::publish` layer (devices / active-renderer-id / dev
|
||||
//! modal), which routes through the B4 `QbzQConnect` bridge.
|
||||
//!
|
||||
//! 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)]
|
||||
|
||||
use std::sync::{Arc, OnceLock, Weak};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::LoggingAdapter;
|
||||
use qconnect_app::{
|
||||
build_session_renderer_snapshot, cache_renderer_snapshot, is_peer_renderer_active,
|
||||
renderer_allows_remote_volume, QconnectApp, QconnectAppEvent, QconnectEventSink,
|
||||
QconnectRemoteSyncState, QconnectRendererEngine, RendererCommand, RendererReport,
|
||||
RendererReportType,
|
||||
};
|
||||
use qconnect_transport_ws::NativeWsTransport;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::qconnect_engine_qt::QtRendererEngine;
|
||||
use crate::qconnect_transport_qt::{resolve_local_identity, BUFFER_STATE_OK};
|
||||
|
||||
/// Concrete `QconnectApp` type used by the Qt adapter.
|
||||
pub type QtQconnectApp = QconnectApp<NativeWsTransport, QtQconnectEventSink>;
|
||||
|
||||
pub struct QtQconnectEventSink {
|
||||
/// Renderer seam — forwards the `qconnect_app::renderer` orchestration onto
|
||||
/// `runtime.core()` + the protected player.
|
||||
engine: QtRendererEngine,
|
||||
/// Shared runtime — used to refresh the Qt now-playing card + queue
|
||||
/// panel from the (remotely-mutated) core state after inbound events.
|
||||
runtime: Arc<AppRuntime<LoggingAdapter>>,
|
||||
/// THE shared remote-sync accumulator (one Mutex, shared with `QconnectApp`).
|
||||
sync_state: Arc<Mutex<QconnectRemoteSyncState>>,
|
||||
/// Late-bound weak handle to the owning app, wired via `set_app` after the
|
||||
/// app is built FROM this sink. Used to emit renderer reports (e.g.
|
||||
/// is_active=true after SetActive(true)) and to drive the session-apply +
|
||||
/// freeze/watchdog without an ownership cycle.
|
||||
app: Arc<OnceLock<Weak<QtQconnectApp>>>,
|
||||
/// FIX #13: previous "a peer is the active renderer" state, tracked across
|
||||
/// `apply_session_management_event` calls. On a false->true transition (QBZ
|
||||
/// becomes a CONTROLLER) we fire one `ask_for_active_renderer_state` to fetch
|
||||
/// the peer's full state (incl. `current_queue_item_id`), so the bar/queue
|
||||
/// resolve the peer's CURRENT track immediately instead of staying stale
|
||||
/// until the peer changes track. Edge-detected to avoid spamming on every
|
||||
/// periodic state-update frame.
|
||||
last_peer_active: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl QtQconnectEventSink {
|
||||
pub fn new(
|
||||
engine: QtRendererEngine,
|
||||
runtime: Arc<AppRuntime<LoggingAdapter>>,
|
||||
sync_state: Arc<Mutex<QconnectRemoteSyncState>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
engine,
|
||||
runtime,
|
||||
sync_state,
|
||||
app: Arc::new(OnceLock::new()),
|
||||
last_peer_active: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the DEV-modal status block (session topology / renderer roles /
|
||||
/// queue) from the live sync state + app snapshot, and push it to the modal.
|
||||
async fn refresh_dev_status(&self) {
|
||||
let Some(app) = self.app.get().and_then(Weak::upgrade) else {
|
||||
return;
|
||||
};
|
||||
let queue = app.queue_state_snapshot().await;
|
||||
let status = {
|
||||
let st = self.sync_state.lock().await;
|
||||
let session = &st.session;
|
||||
let role = match (session.active_renderer_id, session.local_renderer_id) {
|
||||
(Some(a), Some(l)) if a == l => "renderer (this device active)",
|
||||
(Some(_), Some(_)) => "controller (peer active)",
|
||||
(Some(_), None) => "joined (local id pending)",
|
||||
_ => "no active renderer",
|
||||
};
|
||||
let renderers = if session.renderers.is_empty() {
|
||||
" (none)".to_string()
|
||||
} else {
|
||||
session
|
||||
.renderers
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let local = if Some(r.renderer_id) == session.local_renderer_id {
|
||||
" LOCAL"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let active = if Some(r.renderer_id) == session.active_renderer_id {
|
||||
" ACTIVE"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
" #{} {}{}{}",
|
||||
r.renderer_id,
|
||||
r.friendly_name.clone().unwrap_or_default(),
|
||||
local,
|
||||
active
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
format!(
|
||||
"Role: {role}\nsession_uuid: {}\nactive_renderer_id: {:?} local_renderer_id: {:?}\nqueue: v{}.{} items={} autoplay={}\nrenderers:\n{}",
|
||||
session
|
||||
.session_uuid
|
||||
.clone()
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
session.active_renderer_id,
|
||||
session.local_renderer_id,
|
||||
queue.version.major,
|
||||
queue.version.minor,
|
||||
queue.queue_items.len(),
|
||||
queue.autoplay_items.len(),
|
||||
renderers,
|
||||
)
|
||||
};
|
||||
crate::qconnect_qt::dev_set_status(status);
|
||||
}
|
||||
|
||||
/// Rebuild the QConnect device-picker model from the live session topology
|
||||
/// and publish it (devices + active-renderer-id). Mirrors the Tauri
|
||||
/// renderer-list source. Maps `session.renderers` -> rows, marking the
|
||||
/// local device (`is-local`, rendered as "Play here") and the active one.
|
||||
async fn refresh_device_list(&self) {
|
||||
let (devices, active_id) = {
|
||||
let st = self.sync_state.lock().await;
|
||||
let session = &st.session;
|
||||
let devices: Vec<crate::qconnect_qt::publish::QconnectDeviceRow> = session
|
||||
.renderers
|
||||
.iter()
|
||||
.map(|r| crate::qconnect_qt::publish::QconnectDeviceRow {
|
||||
renderer_id: r.renderer_id,
|
||||
name: r
|
||||
.friendly_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown device".to_string()),
|
||||
is_local: Some(r.renderer_id) == session.local_renderer_id,
|
||||
is_active: Some(r.renderer_id) == session.active_renderer_id,
|
||||
icon: device_icon_key(r.device_type, r.friendly_name.as_deref().unwrap_or("")),
|
||||
})
|
||||
.collect();
|
||||
(devices, session.active_renderer_id.unwrap_or(-1))
|
||||
};
|
||||
|
||||
crate::qconnect_qt::publish::devices(devices);
|
||||
crate::qconnect_qt::publish::active_renderer_id(active_id);
|
||||
}
|
||||
|
||||
/// Push the cast-aware now-playing state (is-remote / cast-target /
|
||||
/// volume-locked) from the live session topology. Replaces the old
|
||||
/// `TODO(slint-qconnect-ui)`. `is-remote` is true when a PEER renderer owns
|
||||
/// playback; `cast-target` is its friendly name; `volume-locked` is true
|
||||
/// when that renderer disallows remote volume. This is THE writer of
|
||||
/// `np_is_remote` / `np_cast_target` (the reserved `now_playing::set_remote`
|
||||
/// seam, named by `cast_qt.rs` as the qconnect event sink's job) and the
|
||||
/// primary writer of `np_remote_volume_locked` (contract §11.3; the second
|
||||
/// write site is the facade's disconnect tail).
|
||||
async fn refresh_now_playing_remote_state(&self) {
|
||||
// Badge gate: if the transport is down (terminal teardown OR a transient
|
||||
// reconnect blip), the renderer/controller badge must read NOT remote. The
|
||||
// in-memory session can still name a peer as active_renderer_id long after
|
||||
// the session ended (freeze sets playing_state=UNKNOWN but leaves
|
||||
// active_renderer_id, and disconnect() only runs on the user toggle, not on
|
||||
// transport-drop / reconnect-exhausted). Mirrors the Tauri
|
||||
// fetchQconnectRuntimeState early-return on !transport_connected. On
|
||||
// reconnect, TransportConnected re-runs this refresh and the repopulated
|
||||
// session restores the badge.
|
||||
let transport_connected = match self.app.get().and_then(Weak::upgrade) {
|
||||
Some(app) => app.state_handle().lock().await.transport_connected,
|
||||
None => false,
|
||||
};
|
||||
let (is_remote, cast_target, volume_locked) = {
|
||||
let st = self.sync_state.lock().await;
|
||||
let session = &st.session;
|
||||
let is_remote = transport_connected && is_peer_renderer_active(session);
|
||||
let active = session.active_renderer_id;
|
||||
let active_info = active.and_then(|active_id| {
|
||||
session.renderers.iter().find(|r| r.renderer_id == active_id)
|
||||
});
|
||||
let cast_target = if is_remote {
|
||||
active_info
|
||||
.and_then(|r| r.friendly_name.clone())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let volume_locked = is_remote
|
||||
&& active_info
|
||||
.map(|r| !renderer_allows_remote_volume(r))
|
||||
.unwrap_or(false);
|
||||
(is_remote, cast_target, volume_locked)
|
||||
};
|
||||
|
||||
crate::now_playing::set_remote(is_remote, &cast_target);
|
||||
crate::now_playing::set_remote_volume_locked(volume_locked);
|
||||
}
|
||||
|
||||
/// Refresh the Qt now-playing card + queue panel from the current core
|
||||
/// state. The inbound renderer orchestration (materialize / apply) mutates
|
||||
/// the core player+queue but does NOT touch the UI; without this the card +
|
||||
/// queue stay on whatever was loaded at connect time while the audio follows
|
||||
/// the remote controller. Reads core `current_track()` so it is authoritative
|
||||
/// regardless of the order QueueUpdated / SetState arrive in.
|
||||
async fn refresh_local_ui(&self) {
|
||||
crate::playback_qt::refresh_now_playing(&self.runtime).await;
|
||||
// The reference also refreshes the queue sidebar here
|
||||
// (`refresh_sidebar(true)`); the Qt queue panel document is the same
|
||||
// data surface, published through the existing `queue_qt::publish` path.
|
||||
crate::queue_qt::publish(&self.runtime).await;
|
||||
self.refresh_transport_modes().await;
|
||||
}
|
||||
|
||||
/// Reflect the cloud-authoritative shuffle/repeat on the bar buttons from the
|
||||
/// materialized CORE queue. The inbound SetShuffleMode / QueueUpdated update
|
||||
/// the CORE queue (the cloud's order + flags), but never the UI button — this
|
||||
/// pushes them. Lightweight (no card/art refresh) so it is safe to call on a
|
||||
/// standalone shuffle/loop command without resetting the now-playing card.
|
||||
/// Matches Tauri, whose button reads `queue.shuffle` (not a per-renderer
|
||||
/// field the cloud never populates for a peer).
|
||||
async fn refresh_transport_modes(&self) {
|
||||
let qs = self.runtime.core().get_queue_state().await;
|
||||
let shuffle_on = qs.shuffle;
|
||||
let repeat_mode = match qs.repeat {
|
||||
qbz_models::RepeatMode::Off => 0,
|
||||
qbz_models::RepeatMode::All => 1,
|
||||
qbz_models::RepeatMode::One => 2,
|
||||
};
|
||||
crate::now_playing::set_shuffle(shuffle_on);
|
||||
crate::now_playing::set_repeat_mode(repeat_mode);
|
||||
}
|
||||
|
||||
/// Wire the owning app after construction. Idempotent (OnceLock).
|
||||
pub fn set_app(&self, app: &Arc<QtQconnectApp>) {
|
||||
let _ = self.app.set(Arc::downgrade(app));
|
||||
}
|
||||
|
||||
/// Emit a StateUpdated report announcing this renderer is now active. Sent
|
||||
/// after SetActive(true) is applied so the controller learns we are ready.
|
||||
async fn report_active_renderer_ready(&self) {
|
||||
let Some(app) = self.app.get().and_then(Weak::upgrade) else {
|
||||
return;
|
||||
};
|
||||
let queue_version = app.queue_state_snapshot().await.version;
|
||||
let report = RendererReport::new(
|
||||
RendererReportType::RndrSrvrStateUpdated,
|
||||
Uuid::new_v4().to_string(),
|
||||
queue_version,
|
||||
serde_json::json!({
|
||||
"is_active": true,
|
||||
"buffer_state": BUFFER_STATE_OK,
|
||||
"queue_version": {
|
||||
"major": queue_version.major,
|
||||
"minor": queue_version.minor
|
||||
}
|
||||
}),
|
||||
);
|
||||
if let Err(err) = app.send_renderer_report_command(report).await {
|
||||
log::warn!("[QConnect] Failed to report active-renderer-ready: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a server session-management event by delegating the locked critical
|
||||
/// section to qconnect-app, then running the post-lock renderer-engine work
|
||||
/// the returned `SessionApplyOutcome` asks for. Mirrors the Tauri
|
||||
/// `apply_session_management_event`; the post-lock ordering (loop mode ->
|
||||
/// local-playback handoff -> projection -> freeze -> watchdog) is identical.
|
||||
async fn apply_session_management_event(&self, message_type: &str, payload: &Value) {
|
||||
let Some(app) = self.app.get().and_then(Weak::upgrade) else {
|
||||
return;
|
||||
};
|
||||
let identity = resolve_local_identity();
|
||||
let outcome = app
|
||||
.apply_session_management_event(message_type, payload, &identity)
|
||||
.await;
|
||||
|
||||
if let Some(loop_mode) = outcome.apply_loop_mode {
|
||||
if let Err(err) = qconnect_app::renderer::apply_remote_loop_mode(&self.engine, loop_mode)
|
||||
.await
|
||||
{
|
||||
log::warn!("[QConnect] Failed to apply remote loop mode: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
if outcome.sync_local_playback {
|
||||
self.sync_local_playback_for_renderer_ownership().await;
|
||||
}
|
||||
|
||||
if let Some(renderer_id) = outcome.remote_projection_renderer_id {
|
||||
self.sync_active_renderer_projection(renderer_id).await;
|
||||
}
|
||||
|
||||
if let Some(renderer_id) = outcome.disconnected_renderer_id {
|
||||
app.freeze_active_renderer_projection(
|
||||
renderer_id,
|
||||
QconnectAppEvent::RendererDisconnected { renderer_id },
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Some((renderer_id, generation)) = outcome.watchdog_arm {
|
||||
app.arm_renderer_watchdog(renderer_id, generation);
|
||||
}
|
||||
|
||||
// FIX #13: when QBZ transitions INTO controller mode (a PEER becomes the
|
||||
// active renderer), the peer's periodic state-update frames carry
|
||||
// `current_queue_item_id: null` (position-only), so on the transition the
|
||||
// cursor/projection can't resolve the peer's CURRENT track and the bar/
|
||||
// queue stay stale until the peer next changes track. Fetch the peer's
|
||||
// FULL state once on the false->true edge so the existing align +
|
||||
// projection + poll-loop refresh resolve the real current track now.
|
||||
let peer_active_now = {
|
||||
let state = self.sync_state.lock().await;
|
||||
is_peer_renderer_active(&state.session)
|
||||
};
|
||||
let was_peer_active = self
|
||||
.last_peer_active
|
||||
.swap(peer_active_now, std::sync::atomic::Ordering::Relaxed);
|
||||
if peer_active_now && !was_peer_active {
|
||||
if let Err(err) = app.ask_for_active_renderer_state().await {
|
||||
log::warn!(
|
||||
"[QConnect] controller entry: ask_for_active_renderer_state failed: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When an active PEER renderer now owns playback, stop our local playback so
|
||||
/// the two don't double-play. Mirrors the Tauri helper, with the engine seam
|
||||
/// in place of `CoreBridge`.
|
||||
async fn sync_local_playback_for_renderer_ownership(&self) {
|
||||
let peer_renderer_active = {
|
||||
let state = self.sync_state.lock().await;
|
||||
is_peer_renderer_active(&state.session)
|
||||
};
|
||||
if !peer_renderer_active {
|
||||
return;
|
||||
}
|
||||
|
||||
let playback_state = self.engine.get_playback_state();
|
||||
if playback_state.track_id == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[QConnect] Stopping local playback because active renderer is a peer (track_id={})",
|
||||
playback_state.track_id
|
||||
);
|
||||
if let Err(err) = self.engine.stop() {
|
||||
log::warn!("[QConnect] Failed to stop local playback after renderer handoff: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the cached projection for the active renderer and, when a peer owns
|
||||
/// playback, align the local queue cursor to the peer's current track (so the
|
||||
/// controller view + a later takeover land on the right track). Mirrors the
|
||||
/// Tauri helper.
|
||||
async fn sync_active_renderer_projection(&self, renderer_id: i32) {
|
||||
let (queue_state, renderer_state, session_loop_mode, should_align_engine) = {
|
||||
let state = self.sync_state.lock().await;
|
||||
let Some(active_renderer_id) = state.session.active_renderer_id else {
|
||||
return;
|
||||
};
|
||||
if active_renderer_id != renderer_id {
|
||||
return;
|
||||
}
|
||||
|
||||
(
|
||||
state.last_remote_queue_state.clone(),
|
||||
state
|
||||
.session_renderer_states
|
||||
.get(&active_renderer_id)
|
||||
.cloned(),
|
||||
state.session_loop_mode,
|
||||
state.session.local_renderer_id != Some(active_renderer_id),
|
||||
)
|
||||
};
|
||||
|
||||
let (Some(queue_state), Some(renderer_state)) = (queue_state, renderer_state) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let renderer_snapshot =
|
||||
build_session_renderer_snapshot(&queue_state, Some(&renderer_state), session_loop_mode);
|
||||
{
|
||||
let mut state = self.sync_state.lock().await;
|
||||
cache_renderer_snapshot(&mut state, &renderer_snapshot);
|
||||
}
|
||||
|
||||
if !should_align_engine {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(current_track) = renderer_snapshot.current_track.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) =
|
||||
qconnect_app::renderer::align_queue_cursor(&self.engine, current_track.track_id).await
|
||||
{
|
||||
log::warn!("[QConnect] Failed to sync peer renderer cursor into engine: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl QconnectEventSink for QtQconnectEventSink {
|
||||
async fn on_event(&self, event: QconnectAppEvent) {
|
||||
match &event {
|
||||
QconnectAppEvent::SessionManagementEvent {
|
||||
message_type,
|
||||
payload,
|
||||
} => {
|
||||
log::info!(
|
||||
"[QConnect] Session management: {} payload={}",
|
||||
message_type,
|
||||
serde_json::to_string(payload).unwrap_or_else(|_| "?".to_string())
|
||||
);
|
||||
self.apply_session_management_event(message_type, payload)
|
||||
.await;
|
||||
}
|
||||
QconnectAppEvent::RendererUpdated(renderer_state) => {
|
||||
log::info!(
|
||||
"[QConnect] Renderer updated: playing_state={:?} volume={:?} position={:?}",
|
||||
renderer_state.playing_state,
|
||||
renderer_state.volume,
|
||||
renderer_state.current_position_ms,
|
||||
);
|
||||
let mut sync_state = self.sync_state.lock().await;
|
||||
cache_renderer_snapshot(&mut sync_state, renderer_state);
|
||||
}
|
||||
QconnectAppEvent::QueueUpdated(queue_state) => {
|
||||
log::debug!(
|
||||
"[QConnect] QueueUpdated: items={} shuffle_mode={} version={}.{}",
|
||||
queue_state.queue_items.len(),
|
||||
queue_state.shuffle_mode,
|
||||
queue_state.version.major,
|
||||
queue_state.version.minor,
|
||||
);
|
||||
{
|
||||
let mut sync_state = self.sync_state.lock().await;
|
||||
sync_state.last_remote_queue_state = Some(queue_state.clone());
|
||||
}
|
||||
if let Err(err) = qconnect_app::renderer::materialize_remote_queue(
|
||||
&self.engine,
|
||||
&self.sync_state,
|
||||
queue_state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("[QConnect] Failed to materialize remote queue: {err}");
|
||||
}
|
||||
// Reflect the remote queue change in the QBZ UI (queue panel +
|
||||
// now-playing card). materialize already set the core queue +
|
||||
// cursor; this just pushes it to Qt.
|
||||
self.refresh_local_ui().await;
|
||||
}
|
||||
QconnectAppEvent::RendererCommandApplied { command, state } => {
|
||||
log::info!("[QConnect] Renderer command applied: {:?}", command);
|
||||
let became_active =
|
||||
matches!(command, RendererCommand::SetActive { active: true });
|
||||
if let Err(err) = qconnect_app::renderer::apply_renderer_command(
|
||||
&self.engine,
|
||||
&self.sync_state,
|
||||
command,
|
||||
state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("[QConnect] Failed to apply renderer command: {err}");
|
||||
} else if became_active {
|
||||
self.report_active_renderer_ready().await;
|
||||
}
|
||||
// A SetState changes the current track / play-state — reflect it
|
||||
// in the QBZ now-playing card + queue cursor highlight. A
|
||||
// standalone SetShuffleMode / SetLoopMode does NOT move the track,
|
||||
// so only refresh the lightweight shuffle/repeat button state (a
|
||||
// full refresh would reset the now-playing card position/art).
|
||||
if matches!(command, RendererCommand::SetState { .. }) {
|
||||
self.refresh_local_ui().await;
|
||||
} else if matches!(
|
||||
command,
|
||||
RendererCommand::SetShuffleMode { .. } | RendererCommand::SetLoopMode { .. }
|
||||
) {
|
||||
self.refresh_transport_modes().await;
|
||||
}
|
||||
}
|
||||
QconnectAppEvent::RendererUnreachable { renderer_id } => {
|
||||
log::warn!("[QConnect] Renderer {renderer_id} unreachable");
|
||||
crate::toast_qt::error(qbz_i18n::t("Qobuz Connect renderer unreachable"));
|
||||
}
|
||||
QconnectAppEvent::RendererDisconnected { renderer_id } => {
|
||||
log::warn!("[QConnect] Renderer {renderer_id} disconnected");
|
||||
crate::toast_qt::error(qbz_i18n::t("Qobuz Connect renderer disconnected"));
|
||||
}
|
||||
QconnectAppEvent::PlaybackError {
|
||||
queue_item_id,
|
||||
error_type,
|
||||
..
|
||||
} => {
|
||||
// TODO(qt-qconnect-ui): when QBZ is the controller, auto-skip the
|
||||
// current item. For now surface the failure. (Reference
|
||||
// TODO(slint-qconnect-ui); kept unwired per §9 D5.)
|
||||
log::warn!(
|
||||
"[QConnect] Playback error on queue_item {queue_item_id}: {error_type:?}"
|
||||
);
|
||||
crate::toast_qt::error(qbz_i18n::t("Track unavailable on Qobuz Connect"));
|
||||
}
|
||||
QconnectAppEvent::ResyncComplete => {
|
||||
log::info!("[QConnect] Post-reconnect resync complete");
|
||||
}
|
||||
QconnectAppEvent::LifecycleChanged { state } => {
|
||||
// TODO(qt-qconnect-ui): drive the connect badge state.
|
||||
// (Reference TODO(slint-qconnect-ui); kept unwired per §9 D6.)
|
||||
log::info!("[QConnect] Lifecycle -> {state:?} (UI badge TODO)");
|
||||
}
|
||||
QconnectAppEvent::Diagnostic {
|
||||
channel, level, ..
|
||||
} => {
|
||||
log::debug!("[QConnect] diagnostic {channel} [{level}]");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// DEV diagnostics: log every event (with a relative timestamp) + refresh
|
||||
// the live status block, so the QconnectDevModal reflects QC state at
|
||||
// runtime without a rebuild.
|
||||
crate::qconnect_qt::dev_push_event(dev_event_line(&event));
|
||||
self.refresh_dev_status().await;
|
||||
|
||||
// Controller-mode UI: rebuild the device picker + push the cast-aware
|
||||
// now-playing state (is-remote / cast-target / volume-locked) from the
|
||||
// live session topology after every event.
|
||||
self.refresh_device_list().await;
|
||||
self.refresh_now_playing_remote_state().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a renderer's `device_type` (+ a name heuristic for web players) to a
|
||||
/// device-icon key, mirroring the Tauri `QconnectBadge.resolveDeviceType`:
|
||||
/// 6 = mobile, 5 = computer (or "web" when the name says web player/browser),
|
||||
/// anything else (3/4/…) = speaker/receiver.
|
||||
fn device_icon_key(device_type: Option<i32>, friendly_name: &str) -> &'static str {
|
||||
match device_type.unwrap_or(5) {
|
||||
6 => "mobile",
|
||||
5 => {
|
||||
let name = friendly_name.to_ascii_lowercase();
|
||||
if name.contains("web player") || name.contains("browser") {
|
||||
"web"
|
||||
} else {
|
||||
"computer"
|
||||
}
|
||||
}
|
||||
_ => "speaker",
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a QConnect event into a one-line DEV-log entry. Big payloads
|
||||
/// (QueueUpdated / SessionManagement) are summarized; the rest use Debug.
|
||||
fn dev_event_line(event: &QconnectAppEvent) -> String {
|
||||
match event {
|
||||
QconnectAppEvent::SessionManagementEvent { message_type, .. } => {
|
||||
format!("SESSION {message_type}")
|
||||
}
|
||||
QconnectAppEvent::QueueUpdated(q) => format!(
|
||||
"QueueUpdated v{}.{} items={} shuffle={}",
|
||||
q.version.major,
|
||||
q.version.minor,
|
||||
q.queue_items.len(),
|
||||
q.shuffle_mode
|
||||
),
|
||||
QconnectAppEvent::RendererUpdated(r) => format!(
|
||||
"RendererUpdated playing={:?} pos={:?}ms vol={:?}",
|
||||
r.playing_state, r.current_position_ms, r.volume
|
||||
),
|
||||
QconnectAppEvent::RendererCommandApplied { command, .. } => format!("Cmd {command:?}"),
|
||||
QconnectAppEvent::RendererUnreachable { renderer_id } => {
|
||||
format!("RendererUnreachable #{renderer_id}")
|
||||
}
|
||||
QconnectAppEvent::RendererDisconnected { renderer_id } => {
|
||||
format!("RendererDisconnected #{renderer_id}")
|
||||
}
|
||||
QconnectAppEvent::PlaybackError {
|
||||
queue_item_id,
|
||||
error_type,
|
||||
..
|
||||
} => format!("PlaybackError qid={queue_item_id} {error_type:?}"),
|
||||
QconnectAppEvent::LifecycleChanged { state } => format!("Lifecycle {state:?}"),
|
||||
QconnectAppEvent::ResyncComplete => "ResyncComplete".to_string(),
|
||||
QconnectAppEvent::TransportConnected => "TransportConnected".to_string(),
|
||||
QconnectAppEvent::TransportDisconnected => "TransportDisconnected".to_string(),
|
||||
QconnectAppEvent::Diagnostic { channel, level, .. } => format!("diag {channel} [{level}]"),
|
||||
other => format!("{other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
//! QConnect transport config + credential discovery + device identity for the
|
||||
//! Qt frontend (block B1 of the 2026-08-01 QConnect Qt-port contract).
|
||||
//!
|
||||
//! Behavior-1:1 port of the Slint `qbz/src/qconnect_transport.rs` (which itself
|
||||
//! ported the Tauri `src-tauri/src/qconnect/transport.rs` config layer). The only
|
||||
//! frontend-specific piece, exactly as in Slint: the Qobuz client is reached via
|
||||
//! `runtime.core().client()` — an `Arc<RwLock<Option<QobuzClient>>>` (may be
|
||||
//! `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).
|
||||
//!
|
||||
//! 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)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::LoggingAdapter;
|
||||
use qconnect_transport_ws::WsTransportConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
const DEFAULT_QCONNECT_DEVICE_BRAND: &str = "QBZ";
|
||||
const DEFAULT_QCONNECT_DEVICE_MODEL: &str = "QBZ";
|
||||
const DEFAULT_QCONNECT_DEVICE_TYPE: i32 = 5; // computer
|
||||
const DEFAULT_QCONNECT_SOFTWARE_PREFIX: &str = "qbz";
|
||||
const QCONNECT_QWS_TOKEN_KIND: &str = "jwt_qws";
|
||||
const QCONNECT_QWS_CREATE_TOKEN_PATH: &str = "/qws/createToken";
|
||||
|
||||
type Runtime = Arc<AppRuntime<LoggingAdapter>>;
|
||||
|
||||
/// Persistent QConnect device identity — delegates to the shared module so the
|
||||
/// Qt install resolves the SAME uuid as Slint (same DB path/key/env override).
|
||||
pub fn resolve_qconnect_device_uuid() -> String {
|
||||
qbz_app::qconnect_identity::resolve_qconnect_device_uuid()
|
||||
}
|
||||
|
||||
/// `qbz/<version>` software-version string for the device-info payload.
|
||||
pub fn resolve_qconnect_software_version() -> String {
|
||||
std::env::var("QBZ_QCONNECT_SOFTWARE_VERSION")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"{DEFAULT_QCONNECT_SOFTWARE_PREFIX}/{}",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_qconnect_device_brand() -> String {
|
||||
std::env::var("QBZ_QCONNECT_DEVICE_BRAND")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_QCONNECT_DEVICE_BRAND.to_string())
|
||||
}
|
||||
|
||||
pub fn resolve_qconnect_device_model() -> String {
|
||||
std::env::var("QBZ_QCONNECT_DEVICE_MODEL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_QCONNECT_DEVICE_MODEL.to_string())
|
||||
}
|
||||
|
||||
pub fn resolve_qconnect_device_type() -> i32 {
|
||||
std::env::var("QBZ_QCONNECT_DEVICE_TYPE")
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<i32>().ok())
|
||||
.unwrap_or(DEFAULT_QCONNECT_DEVICE_TYPE)
|
||||
}
|
||||
|
||||
pub fn resolve_system_hostname() -> String {
|
||||
if let Ok(h) = std::env::var("HOSTNAME") {
|
||||
if !h.trim().is_empty() {
|
||||
return h.trim().to_string();
|
||||
}
|
||||
}
|
||||
if let Ok(h) = std::fs::read_to_string("/etc/hostname") {
|
||||
let trimmed = h.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
"Desktop".to_string()
|
||||
}
|
||||
|
||||
/// Returns "Qbz - {hostname}" as the default device name.
|
||||
pub fn resolve_default_qconnect_device_name() -> String {
|
||||
format!("Qbz - {}", resolve_system_hostname())
|
||||
}
|
||||
|
||||
/// Effective friendly name: custom override -> env -> "Qbz - {hostname}".
|
||||
pub fn resolve_qconnect_friendly_name(custom_name: Option<&str>) -> String {
|
||||
custom_name
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
std::env::var("QBZ_QCONNECT_DEVICE_NAME")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
})
|
||||
.unwrap_or_else(resolve_default_qconnect_device_name)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device-info + join payload shapes (piece a). Mirror the Tauri adapter
|
||||
// `types.rs` `QconnectDeviceInfoPayload` / `QconnectDeviceCapabilitiesPayload` /
|
||||
// `QconnectJoinSessionRequest` exactly so the wire frames the server sees are
|
||||
// byte-identical between the two frontends. Built from the device-identity
|
||||
// resolvers above; the uuid delegates to the shared `qbz_app::qconnect_identity`
|
||||
// so a device keeps ONE identity across both frontends.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// AudioQuality wire levels: 1=mp3, 4=hires_l2(192k). Capabilities advertise the
|
||||
// device's min/max decode support; VOLUME_REMOTE_CONTROL_ALLOWED(2) means a
|
||||
// controller may set our volume.
|
||||
pub const AUDIO_QUALITY_MP3: i32 = 1;
|
||||
pub const AUDIO_QUALITY_HIRES_LEVEL2: i32 = 4;
|
||||
const VOLUME_REMOTE_CONTROL_ALLOWED: i32 = 2;
|
||||
/// Renderer buffer-state wire value for OK/ready (mirrors the Tauri adapter).
|
||||
pub const BUFFER_STATE_OK: i32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct QconnectDeviceCapabilitiesPayload {
|
||||
pub min_audio_quality: Option<i32>,
|
||||
pub max_audio_quality: Option<i32>,
|
||||
pub volume_remote_control: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct QconnectDeviceInfoPayload {
|
||||
pub device_uuid: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub brand: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub serial_number: Option<String>,
|
||||
pub device_type: Option<i32>,
|
||||
pub capabilities: Option<QconnectDeviceCapabilitiesPayload>,
|
||||
pub software_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct QconnectJoinSessionRequest {
|
||||
pub session_uuid: Option<String>,
|
||||
pub device_info: Option<QconnectDeviceInfoPayload>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller-mode transport request DTOs (Qt CONTROLLER port). Mirror the
|
||||
// Tauri adapter `types.rs` (`QconnectQueueVersionPayload`,
|
||||
// `QconnectSetPlayerStateQueueItemPayload`, `QconnectSetPlayerStateRequest`,
|
||||
// `QconnectSetVolumeRequest`, `QconnectMuteVolumeRequest`) field-for-field so the
|
||||
// wire frames the cloud parses are byte-identical between both frontends. Do NOT
|
||||
// rename/reorder optionals.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QconnectQueueVersionPayload {
|
||||
pub major: u64,
|
||||
pub minor: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct QconnectSetPlayerStateQueueItemPayload {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub queue_version: Option<QconnectQueueVersionPayload>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<i32>,
|
||||
}
|
||||
|
||||
// `skip_serializing_if` is LOAD-BEARING here: a `None` field must be OMITTED, not
|
||||
// serialized as JSON `null`. The protocol mapper rejects a `current_queue_item`
|
||||
// that is present-but-null ("must be an object") and aborts the whole send, so a
|
||||
// bare play/pause toggle (which sends only `playing_state`) would never reach the
|
||||
// wire without these. Each SetPlayerState field is independently optional per the
|
||||
// protocol, so omitting them is the intended "do one or several" behavior.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct QconnectSetPlayerStateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub playing_state: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current_position: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current_queue_item: Option<QconnectSetPlayerStateQueueItemPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct QconnectSetVolumeRequest {
|
||||
pub renderer_id: Option<i32>,
|
||||
pub volume: Option<i32>,
|
||||
pub volume_delta: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct QconnectMuteVolumeRequest {
|
||||
pub renderer_id: Option<i32>,
|
||||
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.
|
||||
///
|
||||
/// `playing_state` is intentionally `None`: a seek must not toggle play/pause.
|
||||
/// Pure mirror of the Tauri `build_set_position_player_state_request`
|
||||
/// (`src-tauri/src/qconnect/commands.rs`).
|
||||
pub fn build_set_position_player_state_request(
|
||||
position_ms: i64,
|
||||
current_queue_item_id: Option<u64>,
|
||||
queue_version: QconnectQueueVersionPayload,
|
||||
) -> QconnectSetPlayerStateRequest {
|
||||
let current_position = i32::try_from(position_ms.max(0)).ok();
|
||||
let current_queue_item = current_queue_item_id.and_then(|qid| {
|
||||
i32::try_from(qid)
|
||||
.ok()
|
||||
.map(|id| QconnectSetPlayerStateQueueItemPayload {
|
||||
queue_version: Some(QconnectQueueVersionPayload {
|
||||
major: queue_version.major,
|
||||
minor: queue_version.minor,
|
||||
}),
|
||||
id: Some(id),
|
||||
})
|
||||
});
|
||||
QconnectSetPlayerStateRequest {
|
||||
playing_state: None,
|
||||
current_position,
|
||||
current_queue_item,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the device-info payload with the EFFECTIVE friendly name: the persisted
|
||||
/// custom device name when one is set, else the default. The persisted name must
|
||||
/// win here (not only at the controller-bootstrap call site that threads it
|
||||
/// explicitly): with a custom name set, announcing the DEFAULT name from the
|
||||
/// renderer join / local identity gives ONE device_uuid TWO friendly names, and
|
||||
/// when the server's ADD_RENDERER omits the uuid the name-fingerprint self-match
|
||||
/// fails -> `local_renderer_id = None` -> every renderer report is dropped by the
|
||||
/// `is_local_renderer_active` gate (mute renderer, frozen controller seekbar).
|
||||
pub fn default_qconnect_device_info() -> QconnectDeviceInfoPayload {
|
||||
default_qconnect_device_info_with_name(load_persisted_device_name().as_deref())
|
||||
}
|
||||
|
||||
/// Build the device-info payload, overriding the friendly name when a custom
|
||||
/// device name is set. Mirrors the Tauri `default_qconnect_device_info_with_name`.
|
||||
pub fn default_qconnect_device_info_with_name(
|
||||
custom_name: Option<&str>,
|
||||
) -> QconnectDeviceInfoPayload {
|
||||
QconnectDeviceInfoPayload {
|
||||
device_uuid: Some(resolve_qconnect_device_uuid()),
|
||||
friendly_name: Some(resolve_qconnect_friendly_name(custom_name)),
|
||||
brand: Some(resolve_qconnect_device_brand()),
|
||||
model: Some(resolve_qconnect_device_model()),
|
||||
serial_number: None,
|
||||
device_type: Some(resolve_qconnect_device_type()),
|
||||
capabilities: Some(QconnectDeviceCapabilitiesPayload {
|
||||
min_audio_quality: Some(AUDIO_QUALITY_MP3),
|
||||
max_audio_quality: Some(AUDIO_QUALITY_HIRES_LEVEL2),
|
||||
volume_remote_control: Some(VOLUME_REMOTE_CONTROL_ALLOWED),
|
||||
}),
|
||||
software_version: Some(resolve_qconnect_software_version()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve THIS device's identity for injection into the frontend-agnostic
|
||||
/// session-apply logic in qconnect-app (`apply_session_management_event` takes a
|
||||
/// `LocalIdentity` so the crate never depends on the persistence layer). Mirrors
|
||||
/// the Tauri `resolve_local_identity`.
|
||||
pub fn resolve_local_identity() -> qconnect_app::LocalIdentity {
|
||||
let info = default_qconnect_device_info();
|
||||
qconnect_app::LocalIdentity {
|
||||
device_uuid: info.device_uuid.unwrap_or_default(),
|
||||
friendly_name: info.friendly_name,
|
||||
brand: info.brand,
|
||||
model: info.model,
|
||||
device_type: info.device_type,
|
||||
}
|
||||
}
|
||||
|
||||
fn qconnect_settings_db_path() -> Option<std::path::PathBuf> {
|
||||
qbz_app::qconnect_identity::qconnect_settings_db_path()
|
||||
}
|
||||
|
||||
/// Load the persisted custom device name (fail-open: None on any error).
|
||||
pub fn load_persisted_device_name() -> Option<String> {
|
||||
let db_path = 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()?;
|
||||
conn.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'device_name'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Persist the custom device name (None clears it).
|
||||
pub fn persist_device_name(name: Option<&str>) {
|
||||
let Some(db_path) = qconnect_settings_db_path() else {
|
||||
return;
|
||||
};
|
||||
let Ok(conn) = rusqlite::Connection::open(&db_path) else {
|
||||
return;
|
||||
};
|
||||
let _ = conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;");
|
||||
let _ = conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)",
|
||||
);
|
||||
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'", []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the QConnect settings DB (key/value table), creating it when missing.
|
||||
/// Fail-open helper for the startup-mode persistence below — mirrors the Tauri
|
||||
/// `startup.rs::open_settings_conn`.
|
||||
fn open_qconnect_settings_conn() -> Option<rusqlite::Connection> {
|
||||
let db_path = 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)
|
||||
}
|
||||
|
||||
/// Load the persisted QConnect startup mode (Settings > Playback,
|
||||
/// "Auto-connect Qobuz Connect on startup"). Same DB + key + values as the
|
||||
/// Tauri app (`startup_mode` = "off" | "on" | "remember_last" in
|
||||
/// `qconnect_settings.db`), so an existing setting survives the Tauri -> Slint
|
||||
/// migration. Fail-open: returns `Off` (the default) when missing or invalid.
|
||||
pub fn load_startup_mode() -> qconnect_app::QconnectStartupMode {
|
||||
let Some(conn) = open_qconnect_settings_conn() else {
|
||||
return qconnect_app::QconnectStartupMode::default();
|
||||
};
|
||||
conn.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'startup_mode'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(qconnect_app::QconnectStartupMode::from_str)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Persist the QConnect startup mode.
|
||||
pub fn save_startup_mode(mode: qconnect_app::QconnectStartupMode) {
|
||||
let Some(conn) = open_qconnect_settings_conn() else {
|
||||
return;
|
||||
};
|
||||
let _ = conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('startup_mode', ?1)",
|
||||
rusqlite::params![mode.as_str()],
|
||||
);
|
||||
}
|
||||
|
||||
/// Load the last-known QConnect on/off state, if recorded. Same key/values as
|
||||
/// Tauri (`last_known_state` = "on" | "off").
|
||||
pub fn load_last_known_state() -> Option<bool> {
|
||||
let conn = open_qconnect_settings_conn()?;
|
||||
let value: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'last_known_state'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.ok();
|
||||
match value.as_deref() {
|
||||
Some("on") => Some(true),
|
||||
Some("off") => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the last-known on/off state. Written from the USER-facing
|
||||
/// connect/disconnect path (the bar toggle) when `startup_mode ==
|
||||
/// RememberLast` — never from internal teardowns (offline force-disconnect,
|
||||
/// bootstrap-failure cleanup), mirroring Tauri's command-layer write-through:
|
||||
/// a transient failure must not clobber a remembered "connected".
|
||||
pub fn save_last_known_state(state: bool) {
|
||||
let Some(conn) = open_qconnect_settings_conn() else {
|
||||
return;
|
||||
};
|
||||
let value = if state { "on" } else { "off" };
|
||||
let _ = conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('last_known_state', ?1)",
|
||||
rusqlite::params![value],
|
||||
);
|
||||
}
|
||||
|
||||
/// Build the WS transport config: env overrides first, then auto-discovery via
|
||||
/// `qws/createToken`. Mirrors the Tauri `resolve_transport_config` (the
|
||||
/// per-option knobs are deferred — the connect path uses defaults + env, same
|
||||
/// as Slint). `require_jwt = true` (hard) and `reconnect_idle_retry_ms = 60s`
|
||||
/// match the hardened Tauri defaults.
|
||||
pub async fn resolve_transport_config(runtime: &Runtime) -> Result<WsTransportConfig, String> {
|
||||
let mut endpoint_url = normalize_opt_string(std::env::var("QBZ_QCONNECT_WS_ENDPOINT").ok());
|
||||
let mut jwt_qws = normalize_opt_string(std::env::var("QBZ_QCONNECT_JWT_QWS").ok())
|
||||
.or_else(|| normalize_opt_string(std::env::var("QBZ_QCONNECT_JWT").ok()));
|
||||
|
||||
if endpoint_url.is_none() || jwt_qws.is_none() {
|
||||
match fetch_qconnect_transport_credentials(runtime).await {
|
||||
Ok((discovered_endpoint, discovered_jwt_qws)) => {
|
||||
endpoint_url = endpoint_url.or(discovered_endpoint);
|
||||
jwt_qws = jwt_qws.or(discovered_jwt_qws);
|
||||
}
|
||||
Err(err) if endpoint_url.is_some() => {
|
||||
log::warn!(
|
||||
"[QConnect] qws/createToken auto-discovery failed, using provided endpoint: {err}"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(format!(
|
||||
"QConnect endpoint_url is required (or QBZ_QCONNECT_WS_ENDPOINT). Auto-discovery via qws/createToken failed: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let endpoint_url = endpoint_url.ok_or_else(|| {
|
||||
"QConnect endpoint_url is required (or QBZ_QCONNECT_WS_ENDPOINT)".to_string()
|
||||
})?;
|
||||
|
||||
let subscribe_channels = if let Ok(raw) = std::env::var("QBZ_QCONNECT_SUBSCRIBE_CHANNELS_HEX") {
|
||||
let channels: Vec<String> = raw
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
parse_subscribe_channels(channels)?
|
||||
} else {
|
||||
// Default QConnect channels: connectionId(0x01), backend(0x02), controllers(0x03)
|
||||
vec![vec![0x01], vec![0x02], vec![0x03]]
|
||||
};
|
||||
|
||||
let mut config = WsTransportConfig::default();
|
||||
config.endpoint_url = endpoint_url;
|
||||
config.jwt_qws = jwt_qws;
|
||||
config.require_jwt = true;
|
||||
config.reconnect_idle_retry_ms = 60_000;
|
||||
config.subscribe_channels = subscribe_channels;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn fetch_qconnect_transport_credentials(
|
||||
runtime: &Runtime,
|
||||
) -> Result<(Option<String>, Option<String>), String> {
|
||||
// Qt difference (same as Slint): the client is Option-wrapped (None before
|
||||
// API init).
|
||||
let client = runtime
|
||||
.core()
|
||||
.client()
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| "qws/createToken requires an initialized API client".to_string())?;
|
||||
|
||||
let app_id = client
|
||||
.app_id()
|
||||
.await
|
||||
.map_err(|err| format!("qws/createToken requires initialized API client: {err}"))?;
|
||||
let user_auth_token = client
|
||||
.auth_token()
|
||||
.await
|
||||
.map_err(|err| format!("qws/createToken requires authenticated user: {err}"))?;
|
||||
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
"X-App-Id",
|
||||
reqwest::header::HeaderValue::from_str(&app_id)
|
||||
.map_err(|_| "invalid X-App-Id header value".to_string())?,
|
||||
);
|
||||
headers.insert(
|
||||
"X-User-Auth-Token",
|
||||
reqwest::header::HeaderValue::from_str(&user_auth_token)
|
||||
.map_err(|_| "invalid X-User-Auth-Token header value".to_string())?,
|
||||
);
|
||||
|
||||
let url = qbz_qobuz::endpoints::build_url(QCONNECT_QWS_CREATE_TOKEN_PATH);
|
||||
let response = client
|
||||
.get_http()
|
||||
.post(&url)
|
||||
.headers(headers)
|
||||
.form(&[
|
||||
("jwt", QCONNECT_QWS_TOKEN_KIND),
|
||||
("user_auth_token_needed", "true"),
|
||||
("strong_auth_needed", "true"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("qws/createToken HTTP request failed: {err}"))?;
|
||||
|
||||
let status = response.status();
|
||||
let payload: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| format!("qws/createToken response decode failed: {err}"))?;
|
||||
|
||||
if !status.is_success() {
|
||||
let preview = serde_json::to_string(&payload)
|
||||
.unwrap_or_else(|_| "<unserializable>".to_string())
|
||||
.chars()
|
||||
.take(300)
|
||||
.collect::<String>();
|
||||
return Err(format!("qws/createToken status {status}: {preview}"));
|
||||
}
|
||||
|
||||
let jwt_qws_payload = payload
|
||||
.get("jwt_qws")
|
||||
.ok_or_else(|| "qws/createToken response missing jwt_qws payload".to_string())?;
|
||||
|
||||
let endpoint_url = normalize_opt_string(
|
||||
jwt_qws_payload
|
||||
.get("endpoint")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string),
|
||||
);
|
||||
let jwt_qws = normalize_opt_string(
|
||||
jwt_qws_payload
|
||||
.get("jwt")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string),
|
||||
);
|
||||
|
||||
if endpoint_url.is_none() {
|
||||
return Err("qws/createToken response missing jwt_qws.endpoint".to_string());
|
||||
}
|
||||
|
||||
Ok((endpoint_url, jwt_qws))
|
||||
}
|
||||
|
||||
fn normalize_opt_string(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_subscribe_channels(items: Vec<String>) -> Result<Vec<Vec<u8>>, String> {
|
||||
items.into_iter().map(|item| decode_hex_channel(&item)).collect()
|
||||
}
|
||||
|
||||
pub fn decode_hex_channel(raw: &str) -> Result<Vec<u8>, String> {
|
||||
let normalized = raw.trim().trim_start_matches("0x").trim_start_matches("0X");
|
||||
if normalized.is_empty() {
|
||||
return Err("empty subscribe channel hex value".to_string());
|
||||
}
|
||||
|
||||
let needs_padding = normalized.len() % 2 != 0;
|
||||
let value = if needs_padding {
|
||||
format!("0{normalized}")
|
||||
} else {
|
||||
normalized.to_string()
|
||||
};
|
||||
|
||||
let mut bytes = Vec::with_capacity(value.len() / 2);
|
||||
let chars: Vec<char> = value.chars().collect();
|
||||
|
||||
for idx in (0..chars.len()).step_by(2) {
|
||||
let pair = [chars[idx], chars[idx + 1]];
|
||||
let hex = pair.iter().collect::<String>();
|
||||
let byte = u8::from_str_radix(&hex, 16)
|
||||
.map_err(|_| format!("invalid subscribe channel hex byte '{hex}' in '{raw}'"))?;
|
||||
bytes.push(byte);
|
||||
}
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -479,8 +479,8 @@ pub async fn remove_all_after(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_in
|
||||
/// queue index once the filter is resolved.
|
||||
///
|
||||
/// POC-NOTE: the Slint arm first offers the move to the QConnect cloud
|
||||
/// (WS-authoritative when connected); there is no QConnect seam in this port,
|
||||
/// so the local core path is the only one.
|
||||
/// (WS-authoritative when connected) — that seam is now ported below, 1:1
|
||||
/// with `queue.rs:686-697`.
|
||||
pub async fn move_track(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
from_page: usize,
|
||||
@@ -502,6 +502,20 @@ pub async fn move_track(
|
||||
if from_q == to_q {
|
||||
return;
|
||||
}
|
||||
// Connected -> the cloud reorders and echoes a QueueUpdated that
|
||||
// materialize_remote_queue applies; do NOT also reorder locally (would
|
||||
// diverge). TRAP (§7): routed reorders never touch the local queue, and
|
||||
// on ERROR there is NO local fallback (Slint queue.rs:688-697).
|
||||
if let Some(svc) = crate::qconnect_qt::service() {
|
||||
match svc.reorder_upcoming_if_remote(from_q, to_q).await {
|
||||
Ok(true) => return,
|
||||
Ok(false) => {} // not connected -> local path
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] queue: reorder handoff failed: {e}");
|
||||
return; // connected but errored -> do NOT local-reorder
|
||||
}
|
||||
}
|
||||
}
|
||||
runtime.core().move_track(from_q, to_q).await;
|
||||
publish(runtime).await;
|
||||
}
|
||||
|
||||