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
This commit is contained in:
vicrodh committed 2026-08-01 20:23:38 -06:00
1 parent 35508b5f87
commit c8559a68b3
55 files changed
+7007 -87

No files matched your search

+5
View File
@@ -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]]
+18
View File
@@ -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"] }
+6 -1
View File
@@ -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",
+6
View File
@@ -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 {
+16
View File
@@ -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 { }
}
+22
View File
@@ -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
}
}
+53 -10
View File
@@ -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 {
+90 -17
View File
@@ -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
}
}
}
}
}
}
}
+360
View File
@@ -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()
}
}
}
}
}
}
}
}
}
+1 -1
View File
@@ -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
+74 -8
View File
@@ -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
+7 -3
View File
@@ -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";
+27 -4
View File
@@ -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);
});
}
+4
View File
@@ -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() {
+4
View File
@@ -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();
+6
View File
@@ -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() {
+86 -10
View File
@@ -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,
+45
View File
@@ -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,
+98 -18
View File
@@ -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"));
}
+56 -8
View File
@@ -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,
+551
View File
@@ -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
+13 -1
View File
@@ -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(),
+25
View File
@@ -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(())
}
+231
View File
@@ -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);
}
}
+570
View File
@@ -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")
}
+622
View File
@@ -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:?}"),
}
}
File diff suppressed because it is too large. Load diff
+605
View File
@@ -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)
}
+16 -2
View File
@@ -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;
}