feat(qt): Qobuz Connect lists renderers on this network and pairs them
A LAN-only receiver — a BluOS player, another QBZ — was invisible until an official app paired it into the session; that is the "it showed up once, for a day" report. The Qt LAN runtime now browses _qobuz-connect._tcp alongside its own receiver (same lifetime: Qobuz Connect enabled and ready), probes every announcement that is not us, and the picker shows the answers under "On this network" with a Pair action. Pairing mints the delegated pair with /qws/delegateAuth for the renderer's app id and hands it over with this controller's session uuid and become_active, exactly as the official controllers do; the cloud then lists the device and its LAN row drops out by uuid. scripts/qconnect-lan-fake-renderer.sh is a receiver that never joins the cloud, for exercising this without a Node. Pending: qbzd parity, the 5-minute retry backoff, become_active=false. cc
This commit is contained in:
14 files changed
+536
-20
No files matched your search
@@ -7618,3 +7618,11 @@ msgstr "Benutzerdefiniert ({} LUFS)"
|
||||
#: qbz-qt/qml/settings/NavigationSettings.qml
|
||||
msgid "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
|
||||
msgstr "Zeigt den Bereich Käufe neben den anderen Bereichen – in der Seitenleiste oder in der Kopfzeile, wo immer sie sind – zum Stöbern und Herunterladen deiner gekauften Musik"
|
||||
|
||||
#: qbz-qt/qml/shell/QconnectFlyout.qml
|
||||
msgid "On this network"
|
||||
msgstr "In diesem Netzwerk"
|
||||
|
||||
#: qbz-qt/qml/shell/QconnectFlyout.qml
|
||||
msgid "Pair"
|
||||
msgstr "Koppeln"
|
||||
@@ -3257,3 +3257,11 @@ msgstr "Custom ({} LUFS)"
|
||||
#: qbz-qt/qml/settings/NavigationSettings.qml
|
||||
msgid "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
|
||||
msgstr "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
|
||||
|
||||
#: qbz-qt/qml/shell/QconnectFlyout.qml
|
||||
msgid "On this network"
|
||||
msgstr "On this network"
|
||||
|
||||
#: qbz-qt/qml/shell/QconnectFlyout.qml
|
||||
msgid "Pair"
|
||||
msgstr "Pair"
|
||||
@@ -7624,3 +7624,11 @@ msgstr "Personalizado ({} LUFS)"
|
||||
#: qbz-qt/qml/settings/NavigationSettings.qml
|
||||
msgid "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
|
||||
msgstr "Muestra la sección Compras junto a las demás secciones (en la barra lateral o en el encabezado, donde estén) para explorar y descargar tu música comprada"
|
||||
|
||||
#: qbz-qt/qml/shell/QconnectFlyout.qml
|
||||
msgid "On this network"
|
||||
msgstr "En esta red"
|
||||
|
||||
#: qbz-qt/qml/shell/QconnectFlyout.qml
|
||||
msgid "Pair"
|
||||
msgstr "Vincular"
|
||||
@@ -7616,3 +7616,11 @@ msgstr "Personnalisé ({} LUFS)"
|
||||
#: qbz-qt/qml/settings/NavigationSettings.qml
|
||||
msgid "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
|
||||
msgstr "Affiche la section Achats à côté des autres sections — dans la barre latérale ou l'en-tête, où qu'elles soient — pour parcourir et télécharger votre musique achetée"
|
||||
|
||||
#: qbz-qt/qml/shell/QconnectFlyout.qml
|
||||
msgid "On this network"
|
||||
msgstr "Sur ce réseau"
|
||||
|
||||
#: qbz-qt/qml/shell/QconnectFlyout.qml
|
||||
msgid "Pair"
|
||||
msgstr "Associer"
|
||||
@@ -7599,3 +7599,11 @@ msgstr "カスタム({} LUFS)"
|
||||
#: qbz-qt/qml/settings/NavigationSettings.qml
|
||||
msgid "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
|
||||
msgstr "購入セクションを他のセクションと同じ場所(サイドバーまたはヘッダー)に表示し、購入した音楽を閲覧・ダウンロードできます"
|
||||
|
||||
#: qbz-qt/qml/shell/QconnectFlyout.qml
|
||||
msgid "On this network"
|
||||
msgstr "このネットワーク上"
|
||||
|
||||
#: qbz-qt/qml/shell/QconnectFlyout.qml
|
||||
msgid "Pair"
|
||||
msgstr "ペアリング"
|
||||
@@ -7606,3 +7606,11 @@ msgstr "Aangepast ({} LUFS)"
|
||||
#: qbz-qt/qml/settings/NavigationSettings.qml
|
||||
msgid "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
|
||||
msgstr "Toont de sectie Aankopen naast de andere secties — in de zijbalk of de koptekst, waar ze ook staan — om je gekochte muziek te bekijken en te downloaden"
|
||||
|
||||
#: qbz-qt/qml/shell/QconnectFlyout.qml
|
||||
msgid "On this network"
|
||||
msgstr "In dit netwerk"
|
||||
|
||||
#: qbz-qt/qml/shell/QconnectFlyout.qml
|
||||
msgid "Pair"
|
||||
msgstr "Koppelen"
|
||||
@@ -7616,3 +7616,11 @@ msgstr "Personalizado ({} LUFS)"
|
||||
#: qbz-qt/qml/settings/NavigationSettings.qml
|
||||
msgid "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
|
||||
msgstr "Mostra a seção Compras ao lado das outras seções (na barra lateral ou no cabeçalho, onde estiverem) para navegar e baixar suas músicas compradas"
|
||||
|
||||
#: qbz-qt/qml/shell/QconnectFlyout.qml
|
||||
msgid "On this network"
|
||||
msgstr "Nesta rede"
|
||||
|
||||
#: qbz-qt/qml/shell/QconnectFlyout.qml
|
||||
msgid "Pair"
|
||||
msgstr "Parear"
|
||||
@@ -7637,3 +7637,11 @@ msgstr "Своё значение ({} LUFS)"
|
||||
#: qbz-qt/qml/settings/NavigationSettings.qml
|
||||
msgid "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
|
||||
msgstr "Показывает раздел «Покупки» рядом с остальными разделами — в боковой панели или в заголовке, где бы они ни были — для просмотра и загрузки купленной музыки"
|
||||
|
||||
#: qbz-qt/qml/shell/QconnectFlyout.qml
|
||||
msgid "On this network"
|
||||
msgstr "В этой сети"
|
||||
|
||||
#: qbz-qt/qml/shell/QconnectFlyout.qml
|
||||
msgid "Pair"
|
||||
msgstr "Подключить"
|
||||
@@ -49,7 +49,8 @@ Popup {
|
||||
|
||||
// The parsed device document (QbzQConnect.devicesJson). Rows:
|
||||
// { renderer_id, name, is_local, is_active, icon } — the exact shape the
|
||||
// bridge header documents.
|
||||
// bridge header documents — plus, for a renderer seen on this network
|
||||
// that the session does not list yet, `lan: true` and `lan_uuid`.
|
||||
readonly property var devices: {
|
||||
try {
|
||||
var d = JSON.parse(QbzQConnect.devicesJson || "[]")
|
||||
@@ -58,6 +59,8 @@ Popup {
|
||||
return []
|
||||
}
|
||||
}
|
||||
readonly property var sessionDevices: root.devices.filter(function (d) { return d.lan !== true })
|
||||
readonly property var lanDevices: root.devices.filter(function (d) { return d.lan === true })
|
||||
|
||||
readonly property var conflictPolicyOptions: [
|
||||
QbzSession.tr("Ask every time", QbzSession.trRev),
|
||||
@@ -329,7 +332,7 @@ Popup {
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: root.devices
|
||||
model: root.sessionDevices
|
||||
|
||||
delegate: Rectangle {
|
||||
id: deviceRow
|
||||
@@ -399,6 +402,78 @@ Popup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Renderers on this network that the session does not
|
||||
// list yet (the controller's LAN half, 2026-09-13). The
|
||||
// official apps show these the same way; picking one
|
||||
// pairs it — delegated credentials over the LAN — and the
|
||||
// cloud then lists it above like any other renderer.
|
||||
Text {
|
||||
visible: root.lanDevices.length > 0
|
||||
width: devRows.width
|
||||
leftPadding: 8
|
||||
topPadding: 8
|
||||
bottomPadding: 2
|
||||
text: QbzSession.tr("On this network", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
Repeater {
|
||||
model: root.lanDevices
|
||||
|
||||
delegate: Rectangle {
|
||||
id: lanRow
|
||||
required property var modelData
|
||||
|
||||
width: devRows.width
|
||||
height: 34
|
||||
radius: theme.radiusSm
|
||||
color: lanArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
spacing: 8
|
||||
QbzIcon {
|
||||
name: lanRow.modelData.icon === "mobile" ? "smartphone"
|
||||
: lanRow.modelData.icon === "computer" ? "monitor"
|
||||
: "speaker"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "secondary"
|
||||
}
|
||||
Text {
|
||||
width: parent.width - 14 - 8 - pairLabel.implicitWidth - 8
|
||||
text: lanRow.modelData.name || ""
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Text {
|
||||
id: pairLabel
|
||||
text: QbzSession.tr("Pair", QbzSession.trRev)
|
||||
color: theme.accent
|
||||
font.pixelSize: 11
|
||||
font.weight: theme.weightSemibold
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: lanArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
QbzQConnect.pairLan(lanRow.modelData.lan_uuid)
|
||||
root.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,13 @@ pub mod qbz_qconnect {
|
||||
#[qinvokable]
|
||||
fn set_active(self: Pin<&mut QbzQConnect>, renderer_id: i32);
|
||||
|
||||
/// Picker row "On this network": hand the renderer this account's
|
||||
/// delegated credentials so it joins the session (LAN pairing, the
|
||||
/// same step the official apps take). `device_uuid` is the row's
|
||||
/// `lan_uuid`.
|
||||
#[qinvokable]
|
||||
fn pair_lan(self: Pin<&mut QbzQConnect>, device_uuid: QString);
|
||||
|
||||
/// Resolve the playback-conflict modal. Values 1..=4 match the visual
|
||||
/// order and are validated again by the Rust facade.
|
||||
#[qinvokable]
|
||||
@@ -252,6 +259,23 @@ impl qbz_qconnect::QbzQConnect {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn pair_lan(self: Pin<&mut Self>, device_uuid: QString) {
|
||||
let uuid = device_uuid.to_string();
|
||||
let Some(service) = crate::qconnect_qt::service() else {
|
||||
crate::toast_qt::error("Qobuz Connect is not running");
|
||||
return;
|
||||
};
|
||||
crate::spawn(async move {
|
||||
match service.pair_lan(&uuid).await {
|
||||
Ok(name) => crate::toast_qt::info(format!("Pairing with {name}…")),
|
||||
Err(err) => {
|
||||
log::warn!("[QConnect LAN] pair_lan({uuid}) failed: {err}");
|
||||
crate::toast_qt::error("Could not pair with that device");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn resolve_playback_conflict(self: Pin<&mut Self>, choice: i32) {
|
||||
crate::qconnect_qt::resolve_playback_conflict_choice(choice);
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@ impl QtQconnectEventSink {
|
||||
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("")),
|
||||
device_uuid: r.device_uuid.clone(),
|
||||
})
|
||||
.collect();
|
||||
(devices, session.active_renderer_id.unwrap_or(-1))
|
||||
|
||||
@@ -5,18 +5,20 @@
|
||||
//! admission slot on one dedicated thread. Cloud validation and transactional
|
||||
//! authority switching deliberately remain outside this adapter.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
use qbz_models::Quality;
|
||||
use qconnect_app::LanProjectionSlot;
|
||||
use qconnect_lan::{
|
||||
ConnectInfo, DeviceType, DisplayInfo, EndpointPolicy, HandoffCandidate, LanError,
|
||||
LanProjection, LanService, LanServiceConfig, MaxAudioQuality,
|
||||
ConnectInfo, DeviceType, DisplayInfo, EndpointPolicy, HandoffCandidate, LanBrowseEvent,
|
||||
LanBrowser, LanControllerClient, LanError, LanProjection, LanRendererCandidate,
|
||||
LanRendererProbe, LanService, LanServiceConfig, MaxAudioQuality,
|
||||
};
|
||||
|
||||
use crate::qconnect_transport_qt::default_qconnect_device_info;
|
||||
@@ -140,10 +142,58 @@ fn build_projection(
|
||||
/// exact order observed from the latest-wins slot without blocking a Tokio
|
||||
/// worker or the Qt UI thread. The callback must provide its own bounded and
|
||||
/// cancellation-safe transaction semantics.
|
||||
/// A renderer seen on this network: the row the controller half adds to the
|
||||
/// picker (`lan: true`) until the cloud lists the device itself.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct LanPickerRow {
|
||||
pub device_uuid: String,
|
||||
pub name: String,
|
||||
pub brand: String,
|
||||
pub model: String,
|
||||
pub device_type: DeviceType,
|
||||
}
|
||||
|
||||
/// What the browser found and what the probe learned, by device uuid.
|
||||
#[derive(Default)]
|
||||
struct LanCandidates {
|
||||
found: HashMap<String, LanRendererCandidate>,
|
||||
probed: HashMap<String, LanRendererProbe>,
|
||||
fullname_uuid: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl LanCandidates {
|
||||
fn rows(&self) -> Vec<LanPickerRow> {
|
||||
let mut rows: Vec<LanPickerRow> = self
|
||||
.probed
|
||||
.iter()
|
||||
.map(|(uuid, probe)| LanPickerRow {
|
||||
device_uuid: uuid.clone(),
|
||||
name: probe.display.friendly_name.clone(),
|
||||
brand: probe.display.brand_display_name.clone(),
|
||||
model: probe.display.model_display_name.clone(),
|
||||
device_type: probe.display.device_type,
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
rows
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_lan_rows(candidates: &StdMutex<LanCandidates>) {
|
||||
let rows = candidates.lock().map(|c| c.rows()).unwrap_or_default();
|
||||
crate::qconnect_qt::publish::set_lan_rows(rows);
|
||||
}
|
||||
|
||||
pub struct QtLanRuntime {
|
||||
service: Option<LanService>,
|
||||
projection: LanProjection,
|
||||
admission_bridge: Option<JoinHandle<()>>,
|
||||
/// The controller half (2026-09-13): browse + probe, same lifetime as
|
||||
/// the receiver above. `None` when the browser could not start — the
|
||||
/// receiver keeps working without it.
|
||||
browser: Option<LanBrowser>,
|
||||
candidates: Arc<StdMutex<LanCandidates>>,
|
||||
client: Option<Arc<LanControllerClient>>,
|
||||
}
|
||||
|
||||
impl QtLanRuntime {
|
||||
@@ -160,11 +210,13 @@ impl QtLanRuntime {
|
||||
Fut: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
let identity = ExistingIdentity::resolve()?;
|
||||
let own_device_uuid = identity.device_uuid.clone();
|
||||
let projection = build_projection(&identity, app_id, max_audio_quality, current_session_id);
|
||||
let config =
|
||||
LanServiceConfig::new(projection.clone(), endpoint_policy, identity.device_uuid);
|
||||
let (mut service, inbox) = LanService::start(config)?;
|
||||
let on_handoff = Arc::new(on_handoff);
|
||||
let browse_handle = runtime_handle.clone();
|
||||
|
||||
let admission_bridge = match std::thread::Builder::new()
|
||||
.name(ADMISSION_THREAD_NAME.to_string())
|
||||
@@ -185,10 +237,77 @@ impl QtLanRuntime {
|
||||
}
|
||||
};
|
||||
|
||||
// The controller half: browse `_qobuz-connect._tcp`, probe every
|
||||
// announcement that is not us, and offer what answers as picker rows.
|
||||
// A failure here only costs the LAN candidates; the receiver stays.
|
||||
let candidates: Arc<StdMutex<LanCandidates>> = Arc::default();
|
||||
let client = match LanControllerClient::new() {
|
||||
Ok(client) => Some(Arc::new(client)),
|
||||
Err(error) => {
|
||||
log::warn!("[QConnect LAN] controller client unavailable: {error}");
|
||||
None
|
||||
}
|
||||
};
|
||||
let browser = client.as_ref().and_then(|client| {
|
||||
let client = Arc::clone(client);
|
||||
let candidates = Arc::clone(&candidates);
|
||||
match LanBrowser::start(Some(own_device_uuid), move |event| match event {
|
||||
LanBrowseEvent::Found(candidate) => {
|
||||
if let Ok(mut c) = candidates.lock() {
|
||||
c.fullname_uuid
|
||||
.insert(candidate.fullname.clone(), candidate.device_uuid.clone());
|
||||
c.found
|
||||
.insert(candidate.device_uuid.clone(), candidate.clone());
|
||||
}
|
||||
let client = Arc::clone(&client);
|
||||
let candidates = Arc::clone(&candidates);
|
||||
browse_handle.spawn(async move {
|
||||
match client.probe(&candidate).await {
|
||||
Ok(probe) => {
|
||||
log::info!(
|
||||
"[QConnect LAN] on this network: {} ({} {}) at {}",
|
||||
probe.display.friendly_name,
|
||||
probe.display.brand_display_name,
|
||||
probe.display.model_display_name,
|
||||
probe.address
|
||||
);
|
||||
if let Ok(mut c) = candidates.lock() {
|
||||
c.probed.insert(candidate.device_uuid.clone(), probe);
|
||||
}
|
||||
publish_lan_rows(&candidates);
|
||||
}
|
||||
Err(error) => log::info!(
|
||||
"[QConnect LAN] {} did not answer the probe: {error}",
|
||||
candidate.instance
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
LanBrowseEvent::Lost(fullname) => {
|
||||
if let Ok(mut c) = candidates.lock() {
|
||||
if let Some(uuid) = c.fullname_uuid.remove(&fullname) {
|
||||
c.found.remove(&uuid);
|
||||
c.probed.remove(&uuid);
|
||||
}
|
||||
}
|
||||
publish_lan_rows(&candidates);
|
||||
}
|
||||
}) {
|
||||
Ok(browser) => Some(browser),
|
||||
Err(error) => {
|
||||
log::warn!("[QConnect LAN] browse unavailable: {error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
service: Some(service),
|
||||
projection,
|
||||
admission_bridge: Some(admission_bridge),
|
||||
browser,
|
||||
candidates,
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -196,6 +315,18 @@ impl QtLanRuntime {
|
||||
self.projection.clone()
|
||||
}
|
||||
|
||||
/// A probed candidate by device uuid, with the HTTP client to reach it.
|
||||
pub(crate) fn lan_candidate(
|
||||
&self,
|
||||
device_uuid: &str,
|
||||
) -> Option<(LanRendererCandidate, LanRendererProbe, Arc<LanControllerClient>)> {
|
||||
let client = Arc::clone(self.client.as_ref()?);
|
||||
let c = self.candidates.lock().ok()?;
|
||||
let candidate = c.found.get(device_uuid)?.clone();
|
||||
let probe = c.probed.get(device_uuid)?.clone();
|
||||
Some((candidate, probe, client))
|
||||
}
|
||||
|
||||
pub fn port(&self) -> Option<u16> {
|
||||
self.service.as_ref().map(LanService::port)
|
||||
}
|
||||
@@ -205,6 +336,13 @@ impl QtLanRuntime {
|
||||
/// bridge to exit. Callers in async or UI contexts must run this method on
|
||||
/// a blocking worker.
|
||||
pub fn shutdown_blocking(&mut self) {
|
||||
if let Some(mut browser) = self.browser.take() {
|
||||
browser.shutdown();
|
||||
if let Ok(mut c) = self.candidates.lock() {
|
||||
*c = LanCandidates::default();
|
||||
}
|
||||
crate::qconnect_qt::publish::set_lan_rows(Vec::new());
|
||||
}
|
||||
if let Some(mut service) = self.service.take() {
|
||||
service.shutdown();
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ use qconnect_app::{
|
||||
QconnectSessionState, QueueCommandType, RendererPlaybackSnapshot, RendererReport,
|
||||
RendererReportType, SessionLoopHost,
|
||||
};
|
||||
use qconnect_lan::EndpointPolicy;
|
||||
use qconnect_lan::{EndpointPolicy, HandoffBody, LanTokenOut};
|
||||
use qconnect_transport_ws::{NativeWsTransport, WsTransportConfig};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::Mutex;
|
||||
@@ -275,6 +275,38 @@ pub(crate) mod publish {
|
||||
/// Icon key from `device_icon_key`: "mobile" | "web" | "computer" |
|
||||
/// "speaker".
|
||||
pub icon: &'static str,
|
||||
/// The cloud's device uuid, when it carries one — what lets a LAN
|
||||
/// candidate drop out of the picker once the session lists it.
|
||||
pub device_uuid: Option<String>,
|
||||
}
|
||||
|
||||
/// The session rows as last published, kept so a LAN change can
|
||||
/// republish the merged list without waiting for a cloud event.
|
||||
static LAST_SESSION_ROWS: std::sync::Mutex<Vec<QconnectDeviceRow>> =
|
||||
std::sync::Mutex::new(Vec::new());
|
||||
/// Renderers on this network, from the LAN controller half.
|
||||
static LAN_ROWS: std::sync::Mutex<Vec<crate::qconnect_lan_qt::LanPickerRow>> =
|
||||
std::sync::Mutex::new(Vec::new());
|
||||
|
||||
fn lan_icon(device_type: qconnect_lan::DeviceType) -> &'static str {
|
||||
use qconnect_lan::DeviceType;
|
||||
match device_type {
|
||||
DeviceType::Phone | DeviceType::Tablet => "mobile",
|
||||
DeviceType::Computer => "computer",
|
||||
_ => "speaker",
|
||||
}
|
||||
}
|
||||
|
||||
/// The LAN half's rows changed: store them and republish the merged list.
|
||||
pub(crate) fn set_lan_rows(rows: Vec<crate::qconnect_lan_qt::LanPickerRow>) {
|
||||
if let Ok(mut slot) = LAN_ROWS.lock() {
|
||||
*slot = rows;
|
||||
}
|
||||
let session_rows = LAST_SESSION_ROWS
|
||||
.lock()
|
||||
.map(|rows| rows.clone())
|
||||
.unwrap_or_default();
|
||||
devices(session_rows);
|
||||
}
|
||||
|
||||
/// `NowPlayingState.qconnect-connected` -> `QbzQConnect.qconnect_connected`
|
||||
@@ -290,21 +322,48 @@ pub(crate) mod publish {
|
||||
/// these keys — the full contract is documented in qconnect_bridge.rs):
|
||||
/// `[{ renderer_id, name, is_local, is_active, icon }]`.
|
||||
pub(crate) fn devices(rows: Vec<QconnectDeviceRow>) {
|
||||
let json = serde_json::to_string(
|
||||
&rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
serde_json::json!({
|
||||
"renderer_id": row.renderer_id,
|
||||
"name": row.name,
|
||||
"is_local": row.is_local,
|
||||
"is_active": row.is_active,
|
||||
"icon": row.icon,
|
||||
})
|
||||
if let Ok(mut slot) = LAST_SESSION_ROWS.lock() {
|
||||
*slot = rows.clone();
|
||||
}
|
||||
// Session rows first, then the LAN candidates the session does not
|
||||
// list yet (`lan: true`, `lan_uuid`): once a paired device is
|
||||
// announced by the cloud its LAN row disappears by uuid.
|
||||
let mut entries: Vec<serde_json::Value> = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
serde_json::json!({
|
||||
"renderer_id": row.renderer_id,
|
||||
"name": row.name,
|
||||
"is_local": row.is_local,
|
||||
"is_active": row.is_active,
|
||||
"icon": row.icon,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
})
|
||||
.collect();
|
||||
let in_session: std::collections::HashSet<String> = rows
|
||||
.iter()
|
||||
.filter_map(|row| row.device_uuid.clone())
|
||||
.collect();
|
||||
if let Ok(lan) = LAN_ROWS.lock() {
|
||||
entries.extend(
|
||||
lan.iter()
|
||||
.filter(|row| !in_session.contains(&row.device_uuid))
|
||||
.map(|row| {
|
||||
serde_json::json!({
|
||||
"renderer_id": -1,
|
||||
"name": row.name,
|
||||
"is_local": false,
|
||||
"is_active": false,
|
||||
"icon": lan_icon(row.device_type),
|
||||
"lan": true,
|
||||
"lan_uuid": row.device_uuid,
|
||||
"brand": row.brand,
|
||||
"model": row.model,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
let json = serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string());
|
||||
crate::qconnect_bridge::ui(move |mut b| {
|
||||
b.as_mut().set_devices_json(QString::from(json.as_str()));
|
||||
});
|
||||
@@ -748,6 +807,9 @@ pub struct QtQconnectService {
|
||||
coordinator: QtDelegationCoordinator,
|
||||
lan: Mutex<Option<QtLanRuntime>>,
|
||||
lan_lifecycle: LanRuntimeLifecycle<QtLanRuntime>,
|
||||
/// The QWS endpoint the live transport uses — the fallback endpoint for
|
||||
/// a delegated QWS token the cloud returns without one (LAN pairing).
|
||||
lan_qws_endpoint: Mutex<Option<String>>,
|
||||
lifecycle_gate: Mutex<()>,
|
||||
/// Controller-mode mirror of the local queue's manual block (#442 "Play
|
||||
/// later"): steers `insert_after` for play-later routing. See the struct
|
||||
@@ -1261,6 +1323,7 @@ impl QtQconnectService {
|
||||
lan_lifecycle: LanRuntimeLifecycle::new(|runtime: &mut QtLanRuntime| {
|
||||
runtime.shutdown_blocking()
|
||||
}),
|
||||
lan_qws_endpoint: Mutex::new(None),
|
||||
lifecycle_gate: Mutex::new(()),
|
||||
controller_manual: Mutex::new(ControllerManualBlock::default()),
|
||||
teardown_incomplete: AtomicBool::new(false),
|
||||
@@ -1445,6 +1508,7 @@ impl QtQconnectService {
|
||||
let endpoint_policy =
|
||||
EndpointPolicy::from_trusted_endpoints(qbz_qobuz::endpoints::BASE_URL, qws_endpoint)
|
||||
.map_err(|_| "qconnect-lan-endpoint-policy-invalid".to_string())?;
|
||||
*self.lan_qws_endpoint.lock().await = Some(qws_endpoint.to_string());
|
||||
let app_id = self
|
||||
.await_while_enabled(enable_token, self.owner_app_id())
|
||||
.await??;
|
||||
@@ -1551,6 +1615,85 @@ impl QtQconnectService {
|
||||
Err("qconnect-lan-disabled-or-owner-superseded".to_string())
|
||||
}
|
||||
|
||||
/// Pair a renderer found on this network into the account's session —
|
||||
/// the step the official apps take when a LAN-only receiver (a BluOS
|
||||
/// player, another QBZ) is picked: `/qws/delegateAuth` for its app id,
|
||||
/// then the LAN handoff carrying OUR session uuid and `become_active`.
|
||||
/// From there the cloud announces it like any other renderer. Never
|
||||
/// account-less: the delegated pair is minted from the owner's session.
|
||||
/// Returns the renderer's friendly name.
|
||||
pub async fn pair_lan(&self, device_uuid: &str) -> Result<String, String> {
|
||||
let (candidate, probe, lan_client) = {
|
||||
let lan = self.lan.lock().await;
|
||||
let runtime = lan
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Qobuz Connect is not running".to_string())?;
|
||||
runtime
|
||||
.lan_candidate(device_uuid)
|
||||
.ok_or_else(|| "That device is no longer on this network".to_string())?
|
||||
};
|
||||
let sync_state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| "Qobuz Connect state is poisoned".to_string())?
|
||||
.runtime
|
||||
.as_ref()
|
||||
.map(|rt| Arc::clone(&rt.sync_state))
|
||||
.ok_or_else(|| "Qobuz Connect is not connected".to_string())?;
|
||||
let session_id = sync_state
|
||||
.lock()
|
||||
.await
|
||||
.session
|
||||
.session_uuid
|
||||
.clone()
|
||||
.ok_or_else(|| "Qobuz Connect has no session yet".to_string())?;
|
||||
let qws_endpoint = self.lan_qws_endpoint.lock().await.clone();
|
||||
let qobuz = self
|
||||
.runtime
|
||||
.core()
|
||||
.client()
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| "Not signed in".to_string())?;
|
||||
let delegated = qobuz
|
||||
.delegate_qconnect_auth(&probe.connect.app_id)
|
||||
.await
|
||||
.map_err(|e| format!("qws/delegateAuth failed: {e}"))?;
|
||||
let (api_endpoint, api_exp, api_jwt) = delegated.jwt_api.into_parts();
|
||||
let (qws_ep, qws_exp, qws_jwt) = delegated.jwt_qws.into_parts();
|
||||
let api_endpoint =
|
||||
api_endpoint.unwrap_or_else(|| qbz_qobuz::endpoints::BASE_URL.to_string());
|
||||
let qws_ep = qws_ep
|
||||
.or(qws_endpoint)
|
||||
.ok_or_else(|| "the QWS endpoint is unknown".to_string())?;
|
||||
let body = HandoffBody {
|
||||
session_id,
|
||||
jwt_api: LanTokenOut {
|
||||
endpoint: api_endpoint,
|
||||
exp: api_exp,
|
||||
jwt: api_jwt,
|
||||
},
|
||||
jwt_qconnect: LanTokenOut {
|
||||
endpoint: qws_ep,
|
||||
exp: qws_exp,
|
||||
jwt: qws_jwt,
|
||||
},
|
||||
become_active: true,
|
||||
};
|
||||
lan_client
|
||||
.hand_off(&candidate, &probe.address, &body)
|
||||
.await
|
||||
.map_err(|e| format!("connect-to-qconnect failed: {e}"))?;
|
||||
log::info!(
|
||||
"[QConnect LAN] paired {} ({} {}) into the session; waiting for the cloud to list it",
|
||||
probe.display.friendly_name,
|
||||
probe.display.brand_display_name,
|
||||
probe.display.model_display_name
|
||||
);
|
||||
Ok(probe.display.friendly_name.clone())
|
||||
}
|
||||
|
||||
async fn stop_lan(&self) -> Result<(), String> {
|
||||
self.delegation_host.detach_projection();
|
||||
let runtime = self.lan.lock().await.take();
|
||||
|
||||
Reference in new issue
Block a user