feat(slint): cast service + transport gating for Chromecast/DLNA (Phase 2-3)

The Slint-side seam over the qbz-cast crate, modeled on qconnect_service.rs.

cast_service.rs (singleton):
- discovery (mDNS + SSDP) with a 2s device-refresh loop and 15s scan window;
- connect/disconnect: snapshot local playback, stop the local backend, suspend
  QConnect (remember + restore), connect, then re-cast the current track
  passing its REAL source (fixes the Tauri resume-source bug) + deferred seek;
- cast_track: source-routed (qobuz via the Phase-0 shared resolver at
  UltraHiRes; local via register_file streaming + rich MIME; plex = TODO),
  one shared lazy MediaServer, per-device source-IP URL, Chromecast auto-play
  vs DLNA two-step load->play;
- 1s position poll mirroring position/duration/elapsed to the bar + CastState,
  with the verbatim track-end heuristic (Chromecast ->IDLE, DLNA ->STOPPED/
  NO_MEDIA_PRESENT) driving auto-advance;
- delivered-quality (D3) pushed to CastState; shutdown() for logout/exit.

state.slint: CastState global + CastActions + CastDevice (tagged record with
DLNA capability flags); re-exported in app.slint.

main.rs: init + CastActions wiring (open/close/refresh/connect/disconnect) and
transport gating — play/pause/next/previous/seek/volume now route Cast >
QConnect > local.

Compiles clean (only stop_if_cast/shutdown unused pending the stop callback +
logout/exit wiring, next).

cc
This commit is contained in:
vicrodh committed 2026-06-18 09:54:27 -06:00
1 parent 8dc5f9c5c5
commit 3e1101ff64
6 files changed
+1152 -1

No files matched your search

+1
View File
@@ -5751,6 +5751,7 @@ dependencies = [
"qbz-app",
"qbz-audio",
"qbz-cache",
"qbz-cast",
"qbz-core",
"qbz-credentials",
"qbz-integrations",
+1
View File
@@ -63,6 +63,7 @@ qbz-media-controls = { path = "../qbz-media-controls" }
qbz-audio = { path = "../qbz-audio" }
qbz-core = { path = "../qbz-core" }
qbz-player = { path = "../qbz-player" }
qbz-cast = { path = "../qbz-cast" }
qconnect-app = { path = "../qconnect-app" }
qconnect-transport-ws = { path = "../qconnect-transport-ws" }
futures-util = { workspace = true }
+978
View File
@@ -0,0 +1,978 @@
//! Cast (Chromecast / DLNA) service for the Slint frontend.
//!
//! Mirrors the Tauri cast integration (the `castStore.ts` behavior + the
//! `commands_v2/library.rs` cast commands) on top of the shared, Tauri-agnostic
//! `qbz-cast` crate. Modeled on `qconnect_service.rs`: a process-wide singleton
//! holding the discovery handles, the active connection, ONE lazy shared
//! `MediaServer`, and a 1s position-poll task. Cast bypasses the local audio
//! backend entirely (the renderer's own DAC decodes the bytes we serve).
//!
//! Bytes + MIME + delivered quality are resolved through the shared core API
//! `CoreBridge::fetch_for_external_stream_resolved` (Phase 0), which attempts
//! UltraHiRes first and reports the real delivered tier (decision D3).
//!
//! Source routing (decision: route by `QueueTrack.source`, never QConnect
//! admission): qobuz -> shared resolver; local -> `register_file` (streams from
//! disk, rich MIME); plex -> TODO (needs the Plex bytes resolver, tracked).
use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use qbz_cast::{
CastPositionInfo, ChromecastHandle, DeviceDiscovery, DiscoveredDevice, DiscoveredDlnaDevice,
DlnaConnection, DlnaDiscovery, DlnaMetadata, DlnaPositionInfo, MediaMetadata, MediaServer,
};
use qbz_models::{QueueTrack, Quality};
use tokio::sync::Mutex;
use crate::adapter::SlintAdapter;
use crate::{AppWindow, CastState, NowPlayingState};
type Runtime = Arc<AppRuntime<SlintAdapter>>;
/// Cast position poll cadence (mirrors Tauri's `POSITION_POLL_INTERVAL_MS`).
const POSITION_POLL_INTERVAL_MS: u64 = 1000;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum CastProtocol {
Chromecast,
Dlna,
}
impl CastProtocol {
fn as_str(self) -> &'static str {
match self {
CastProtocol::Chromecast => "chromecast",
CastProtocol::Dlna => "dlna",
}
}
fn from_str(s: &str) -> Option<Self> {
match s {
"chromecast" => Some(CastProtocol::Chromecast),
"dlna" => Some(CastProtocol::Dlna),
_ => None,
}
}
}
// ---- Module singleton -------------------------------------------------------
static SERVICE: std::sync::OnceLock<Arc<SlintCastService>> = std::sync::OnceLock::new();
/// Initialize the Cast service singleton (idempotent).
pub fn init_service(runtime: Runtime, window: slint::Weak<AppWindow>) -> Arc<SlintCastService> {
SERVICE
.get_or_init(|| Arc::new(SlintCastService::new(runtime, window)))
.clone()
}
/// The initialized Cast service, if shell setup has run.
pub fn service() -> Option<Arc<SlintCastService>> {
SERVICE.get().cloned()
}
// ---- State ------------------------------------------------------------------
#[derive(Default)]
struct CastInner {
// Discovery (started while the picker is open).
chromecast_discovery: Option<DeviceDiscovery>,
dlna_discovery: Option<DlnaDiscovery>,
// Active connection (exactly one protocol at a time).
chromecast: Option<ChromecastHandle>,
dlna: Option<DlnaConnection>,
protocol: Option<CastProtocol>,
connected_device_ip: Option<String>,
connected_device_name: Option<String>,
// ONE shared lazy media server for both protocols.
media_server: Option<MediaServer>,
// Playback mirror.
current_track_id: Option<u64>,
is_playing: bool,
// Track-end one-shot latch (reset on PLAYING).
track_end_detected: bool,
// QConnect coexistence: remember whether QConnect was on before casting.
qconnect_was_on_before_cast: bool,
// Position-poll task; aborted on disconnect.
poll_task: Option<tokio::task::JoinHandle<()>>,
// Device-refresh task (2s loop while the picker is open).
discovery_task: Option<tokio::task::JoinHandle<()>>,
}
/// Picker device-list poll cadence + scan-spinner window (mirror Tauri).
const DEVICE_POLL_INTERVAL_MS: u64 = 2000;
const SCAN_DURATION_MS: u64 = 15000;
pub struct SlintCastService {
inner: Arc<Mutex<CastInner>>,
runtime: Runtime,
window: slint::Weak<AppWindow>,
}
impl SlintCastService {
pub fn new(runtime: Runtime, window: slint::Weak<AppWindow>) -> Self {
Self {
inner: Arc::new(Mutex::new(CastInner::default())),
runtime,
window,
}
}
/// True while a renderer is connected and owns transport.
pub async fn is_casting(&self) -> bool {
self.inner.lock().await.protocol.is_some()
}
// ---- Discovery ----------------------------------------------------------
/// Start mDNS (Chromecast) + SSDP (DLNA) discovery, the 2s device-refresh
/// loop, and the 15s scan-spinner window. Picker-owned.
pub async fn start_discovery(self: &Arc<Self>) {
{
let mut inner = self.inner.lock().await;
if inner.chromecast_discovery.is_none() {
let mut disco = DeviceDiscovery::new();
if let Err(e) = disco.start_discovery() {
log::warn!("[Cast] chromecast discovery start failed: {e}");
}
inner.chromecast_discovery = Some(disco);
}
if inner.dlna_discovery.is_none() {
let mut disco = DlnaDiscovery::new();
if let Err(e) = disco.start_discovery().await {
log::warn!("[Cast] dlna discovery start failed: {e}");
}
inner.dlna_discovery = Some(disco);
}
}
// Arm the scan-spinner window.
self.set_scanning(true);
let svc_scan = self.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(SCAN_DURATION_MS)).await;
svc_scan.set_scanning(false);
});
// 2s device-refresh loop (replaces any prior).
let svc = self.clone();
let task = tokio::spawn(async move {
loop {
svc.refresh_devices().await;
tokio::time::sleep(std::time::Duration::from_millis(DEVICE_POLL_INTERVAL_MS)).await;
}
});
let mut inner = self.inner.lock().await;
if let Some(old) = inner.discovery_task.replace(task) {
old.abort();
}
}
/// Stop both discoveries + the refresh loop (picker closed). The active
/// connection is untouched.
pub async fn stop_discovery(&self) {
let mut inner = self.inner.lock().await;
if let Some(task) = inner.discovery_task.take() {
task.abort();
}
if let Some(mut disco) = inner.chromecast_discovery.take() {
let _ = disco.stop_discovery();
}
if let Some(mut disco) = inner.dlna_discovery.take() {
let _ = disco.stop_discovery();
}
}
fn set_scanning(&self, scanning: bool) {
let weak = self.window.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
use slint::ComponentHandle;
w.global::<CastState>().set_scanning(scanning);
});
}
/// Snapshot both device lists and push them to `CastState` for the picker.
pub async fn refresh_devices(&self) {
let (chromecast, dlna) = {
let inner = self.inner.lock().await;
let cc = inner
.chromecast_discovery
.as_ref()
.map(|d| d.get_discovered_devices())
.unwrap_or_default();
let dl = inner
.dlna_discovery
.as_ref()
.map(|d| d.get_discovered_devices())
.unwrap_or_default();
(cc, dl)
};
self.push_devices(chromecast, dlna);
}
// ---- Connect / disconnect ----------------------------------------------
/// Connect to a device, halting local playback and suspending QConnect, then
/// re-cast the current track if one was playing. Mirrors
/// `castStore.connectToDevice`.
pub async fn connect(self: &Arc<Self>, device_id: String, protocol: String) -> Result<(), String> {
let proto = CastProtocol::from_str(&protocol)
.ok_or_else(|| format!("Unknown cast protocol: {protocol}"))?;
// Snapshot local playback BEFORE we tear it down.
let snapshot_track = self.runtime.core().current_track().await;
let pb = self.runtime.core().get_playback_state();
let was_playing = pb.is_playing;
let resume_pos = pb.position;
// Halt the local audio backend (no double audio).
let _ = self.runtime.core().stop();
// Suspend QConnect if it was on (best-effort; never block casting).
self.suspend_qconnect_if_on().await;
// Connect to the renderer.
let device_ip = match proto {
CastProtocol::Chromecast => self.connect_chromecast(&device_id).await?,
CastProtocol::Dlna => self.connect_dlna(&device_id).await?,
};
{
let mut inner = self.inner.lock().await;
inner.protocol = Some(proto);
inner.connected_device_ip = Some(device_ip);
inner.track_end_detected = false;
}
self.push_connection_state().await;
self.start_position_poll();
// Re-cast the current track at its position, passing the REAL source
// (fixes the Tauri resume-source bug where Plex re-cast as Qobuz).
if was_playing {
if let Some(track) = snapshot_track {
if let Err(e) = self.cast_track(&track).await {
log::warn!("[Cast] resume re-cast failed: {e}");
} else if resume_pos > 5 {
// Deferred seek to the prior position (renderer needs the
// media loaded first).
let svc = self.clone();
let pos = resume_pos as f64;
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let _ = svc.seek_secs(pos).await;
});
}
}
}
Ok(())
}
async fn connect_chromecast(&self, device_id: &str) -> Result<String, String> {
let device: DiscoveredDevice = {
let inner = self.inner.lock().await;
inner
.chromecast_discovery
.as_ref()
.and_then(|d| d.get_device(device_id))
.ok_or_else(|| format!("Chromecast device not found: {device_id}"))?
};
let handle = ChromecastHandle::new();
handle
.connect(device.ip.clone(), device.port)
.map_err(|e| e.to_string())?;
let mut inner = self.inner.lock().await;
inner.chromecast = Some(handle);
inner.connected_device_name = Some(device.name.clone());
Ok(device.ip)
}
async fn connect_dlna(&self, device_id: &str) -> Result<String, String> {
// `DlnaConnection::connect` consumes the discovered device by value.
let device: DiscoveredDlnaDevice = {
let inner = self.inner.lock().await;
inner
.dlna_discovery
.as_ref()
.and_then(|d| d.get_device(device_id))
.ok_or_else(|| format!("DLNA device not found: {device_id}"))?
};
let ip = device.ip.clone();
let name = device.name.clone();
let conn = DlnaConnection::connect(device)
.await
.map_err(|e| e.to_string())?;
let mut inner = self.inner.lock().await;
inner.dlna = Some(conn);
inner.connected_device_name = Some(name);
Ok(ip)
}
/// Disconnect: stop the renderer, drop the connection, restore QConnect,
/// reset state. Mirrors `castStore.disconnect`.
pub async fn disconnect(&self) {
// Stop the renderer first (disconnect alone leaves it playing).
let _ = self.stop_renderer().await;
let (poll, was_on) = {
let mut inner = self.inner.lock().await;
if let Some(h) = inner.chromecast.take() {
let _ = h.disconnect();
}
if let Some(mut c) = inner.dlna.take() {
let _ = c.disconnect();
}
inner.protocol = None;
inner.connected_device_ip = None;
inner.connected_device_name = None;
inner.current_track_id = None;
inner.is_playing = false;
inner.track_end_detected = false;
(inner.poll_task.take(), inner.qconnect_was_on_before_cast)
};
if let Some(task) = poll {
task.abort();
}
if was_on {
self.restore_qconnect().await;
self.inner.lock().await.qconnect_was_on_before_cast = false;
}
self.push_connection_state().await;
}
// ---- Casting a track ----------------------------------------------------
/// Resolve a track's bytes + MIME, register them with the shared media
/// server, and hand the URL to the active renderer. Routes by source.
pub async fn cast_track(self: &Arc<Self>, track: &QueueTrack) -> Result<(), String> {
let proto = {
let inner = self.inner.lock().await;
inner.protocol.ok_or_else(|| "Not connected".to_string())?
};
let source = if track.is_local {
"local"
} else {
track.source.as_deref().unwrap_or("qobuz")
};
// Resolve (content_type, register-closure) per source. The network
// download happens OUTSIDE the inner lock.
let (content_type, quality_label, quality_detail) = match source {
"local" | "ephemeral" => {
let path = resolve_local_path(track.id)
.ok_or_else(|| format!("Local file not found for track {}", track.id))?;
self.register_local(track.id, &path).await?
}
"qobuz" | "qobuz_download" => self.register_qobuz(track.id).await?,
"plex" => {
// TODO(cast-plex): Plex casting needs the Plex bytes resolver
// (baseUrl/token/ratingKey -> proxied bytes). Not yet wired in
// the Slint frontend; tracked for a follow-up slice.
return Err("Plex casting is not yet supported".to_string());
}
other => return Err(format!("Unsupported cast source: {other}")),
};
// Build the per-device URL and hand it to the renderer.
let url = {
let inner = self.inner.lock().await;
let ip = inner.connected_device_ip.clone();
let server = inner
.media_server
.as_ref()
.ok_or_else(|| "Media server not initialized".to_string())?;
match ip.as_deref() {
Some(ip) => server.get_audio_url_for_target(track.id, ip),
None => server.get_audio_url(track.id),
}
.ok_or_else(|| "Failed to build media URL".to_string())?
};
match proto {
CastProtocol::Chromecast => {
let inner = self.inner.lock().await;
let handle = inner.chromecast.as_ref().ok_or("Chromecast not connected")?;
// load_media auto-plays on the Default Media Receiver.
handle
.load_media(url, content_type, media_metadata(track))
.map_err(|e| e.to_string())?;
}
CastProtocol::Dlna => {
let mut inner = self.inner.lock().await;
let conn = inner.dlna.as_mut().ok_or("DLNA not connected")?;
// DLNA is a TWO-step load -> play.
conn.load_media(&url, &dlna_metadata(track), &content_type)
.await
.map_err(|e| e.to_string())?;
conn.play().await.map_err(|e| e.to_string())?;
}
}
{
let mut inner = self.inner.lock().await;
inner.current_track_id = Some(track.id);
inner.is_playing = true;
inner.track_end_detected = false;
}
self.push_quality(quality_label, quality_detail).await;
self.push_connection_state().await;
Ok(())
}
/// qobuz: resolve via the shared core API (UltraHiRes-first), register the
/// bytes. Returns (content_type, quality_label, quality_detail).
async fn register_qobuz(
&self,
track_id: u64,
) -> Result<(String, String, String), String> {
let asset = self
.runtime
.core()
.fetch_for_external_stream_resolved(track_id, Quality::UltraHiRes)
.await
.ok_or_else(|| format!("Could not resolve stream for track {track_id}"))?;
let content_type = asset.content_type.clone();
let quality_label = asset.quality.tier_label().to_string();
let quality_detail = format_quality_detail(&asset.quality);
self.ensure_media_server().await?;
{
let mut inner = self.inner.lock().await;
let server = inner.media_server.as_mut().ok_or("Media server gone")?;
server.register_audio(track_id, asset.bytes, &content_type);
}
Ok((content_type, quality_label, quality_detail))
}
/// local: stream the file from disk via register_file (no full-RAM read);
/// the crate's rich MIME map sets the content type.
async fn register_local(
&self,
track_id: u64,
path: &str,
) -> Result<(String, String, String), String> {
self.ensure_media_server().await?;
let content_type = {
let mut inner = self.inner.lock().await;
let server = inner.media_server.as_mut().ok_or("Media server gone")?;
server
.register_file(track_id, path)
.map_err(|e| e.to_string())?;
// Re-read the entry's content type would need an accessor; recompute
// from the path for the UI label (cheap, matches the crate map).
content_type_for_local(path)
};
// Local files carry no resolved Qobuz quality tier.
Ok((content_type, String::new(), String::new()))
}
async fn ensure_media_server(&self) -> Result<(), String> {
let mut inner = self.inner.lock().await;
if inner.media_server.is_none() {
let server = MediaServer::start().map_err(|e| e.to_string())?;
inner.media_server = Some(server);
}
Ok(())
}
// ---- Transport (cast-first gating; mirrors qconnect *_if_remote) --------
/// Toggle play/pause on the renderer. Ok(false) = not casting (fall through
/// to local). Ok(true) = handled.
pub async fn toggle_play_if_cast(&self) -> Result<bool, String> {
let (proto, playing) = {
let inner = self.inner.lock().await;
match inner.protocol {
Some(p) => (p, inner.is_playing),
None => return Ok(false),
}
};
if playing {
self.pause_renderer(proto).await?;
} else {
self.play_renderer(proto).await?;
}
self.inner.lock().await.is_playing = !playing;
self.push_connection_state().await;
Ok(true)
}
pub async fn seek_if_cast(&self, secs: f64) -> Result<bool, String> {
if !self.is_casting().await {
return Ok(false);
}
self.seek_secs(secs).await?;
Ok(true)
}
pub async fn set_volume_if_cast(&self, volume: f32) -> Result<bool, String> {
let proto = {
let inner = self.inner.lock().await;
match inner.protocol {
Some(p) => p,
None => return Ok(false),
}
};
let v = volume.clamp(0.0, 1.0);
match proto {
CastProtocol::Chromecast => {
let inner = self.inner.lock().await;
if let Some(h) = inner.chromecast.as_ref() {
h.set_volume(v).map_err(|e| e.to_string())?;
}
}
CastProtocol::Dlna => {
let mut inner = self.inner.lock().await;
if let Some(c) = inner.dlna.as_mut() {
c.set_volume(v).await.map_err(|e| e.to_string())?;
}
}
}
Ok(true)
}
pub async fn stop_if_cast(&self) -> Result<bool, String> {
if !self.is_casting().await {
return Ok(false);
}
self.stop_renderer().await?;
self.inner.lock().await.is_playing = false;
self.push_connection_state().await;
Ok(true)
}
/// Advance the QBZ queue and cast the new current track. Ok(false) = not
/// casting.
pub async fn next_if_cast(self: &Arc<Self>) -> Result<bool, String> {
if !self.is_casting().await {
return Ok(false);
}
if let Some(track) = self.runtime.core().next_track().await {
self.cast_track(&track).await?;
}
Ok(true)
}
pub async fn previous_if_cast(self: &Arc<Self>) -> Result<bool, String> {
if !self.is_casting().await {
return Ok(false);
}
if let Some(track) = self.runtime.core().previous_track().await {
self.cast_track(&track).await?;
}
Ok(true)
}
async fn seek_secs(&self, secs: f64) -> Result<(), String> {
let proto = {
let inner = self.inner.lock().await;
match inner.protocol {
Some(p) => p,
None => return Ok(()),
}
};
match proto {
CastProtocol::Chromecast => {
let inner = self.inner.lock().await;
if let Some(h) = inner.chromecast.as_ref() {
h.seek(secs).map_err(|e| e.to_string())?;
}
}
CastProtocol::Dlna => {
let mut inner = self.inner.lock().await;
if let Some(c) = inner.dlna.as_mut() {
c.seek(secs.max(0.0) as u64)
.await
.map_err(|e| e.to_string())?;
}
}
}
Ok(())
}
async fn play_renderer(&self, proto: CastProtocol) -> Result<(), String> {
match proto {
CastProtocol::Chromecast => {
let inner = self.inner.lock().await;
if let Some(h) = inner.chromecast.as_ref() {
h.play().map_err(|e| e.to_string())?;
}
}
CastProtocol::Dlna => {
let mut inner = self.inner.lock().await;
if let Some(c) = inner.dlna.as_mut() {
c.play().await.map_err(|e| e.to_string())?;
}
}
}
Ok(())
}
async fn pause_renderer(&self, proto: CastProtocol) -> Result<(), String> {
match proto {
CastProtocol::Chromecast => {
let inner = self.inner.lock().await;
if let Some(h) = inner.chromecast.as_ref() {
h.pause().map_err(|e| e.to_string())?;
}
}
CastProtocol::Dlna => {
let mut inner = self.inner.lock().await;
if let Some(c) = inner.dlna.as_mut() {
c.pause().await.map_err(|e| e.to_string())?;
}
}
}
Ok(())
}
async fn stop_renderer(&self) -> Result<(), String> {
let proto = {
let inner = self.inner.lock().await;
match inner.protocol {
Some(p) => p,
None => return Ok(()),
}
};
match proto {
CastProtocol::Chromecast => {
let inner = self.inner.lock().await;
if let Some(h) = inner.chromecast.as_ref() {
h.stop().map_err(|e| e.to_string())?;
}
}
CastProtocol::Dlna => {
let mut inner = self.inner.lock().await;
if let Some(c) = inner.dlna.as_mut() {
c.stop().await.map_err(|e| e.to_string())?;
}
}
}
Ok(())
}
// ---- Position poll + ended detection ------------------------------------
fn start_position_poll(self: &Arc<Self>) {
let svc = self.clone();
let task = tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_millis(POSITION_POLL_INTERVAL_MS))
.await;
if !svc.is_casting().await {
break;
}
svc.poll_once().await;
}
});
// Replace any prior task.
let svc2 = self.clone();
tokio::spawn(async move {
let mut inner = svc2.inner.lock().await;
if let Some(old) = inner.poll_task.replace(task) {
old.abort();
}
});
}
async fn poll_once(self: &Arc<Self>) {
let proto = {
let inner = self.inner.lock().await;
match inner.protocol {
Some(p) => p,
None => return,
}
};
// Read position/state from the active renderer.
let (position, duration, state, playing) = match proto {
CastProtocol::Chromecast => {
let info: Option<CastPositionInfo> = {
let inner = self.inner.lock().await;
inner.chromecast.as_ref().and_then(|h| h.get_media_position().ok())
};
match info {
Some(i) => {
let st = i.player_state.to_uppercase();
let playing = st == "PLAYING";
(i.position_secs, i.duration_secs, st, playing)
}
None => return,
}
}
CastProtocol::Dlna => {
let info: Option<DlnaPositionInfo> = {
let inner = self.inner.lock().await;
match inner.dlna.as_ref() {
Some(c) => c.get_position_info().await.ok(),
None => None,
}
};
match info {
Some(i) => {
let st = i.transport_state.to_uppercase();
let playing = st == "PLAYING";
(i.position_secs as f64, i.duration_secs as f64, st, playing)
}
None => return,
}
}
};
// Track-end detection (mirrors castStore): Chromecast {PLAYING,BUFFERING}
// -> IDLE; DLNA PLAYING -> {STOPPED, NO_MEDIA_PRESENT}. One-shot latch,
// reset on PLAYING.
let ended = {
let mut inner = self.inner.lock().await;
inner.is_playing = playing;
let ended = match proto {
CastProtocol::Chromecast => state == "IDLE" && !inner.track_end_detected,
CastProtocol::Dlna => {
matches!(state.as_str(), "STOPPED" | "NO_MEDIA_PRESENT")
&& !inner.track_end_detected
}
};
if state == "PLAYING" {
inner.track_end_detected = false;
} else if ended {
inner.track_end_detected = true;
}
ended
};
// Push position to CastState + the now-playing bar (the local poll is
// stopped while casting, so the cast poll drives the bar).
let weak = self.window.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
use slint::ComponentHandle;
let cs = w.global::<CastState>();
cs.set_position_secs(position as f32);
cs.set_duration_secs(duration as f32);
cs.set_is_playing(playing);
let np = w.global::<NowPlayingState>();
np.set_progress((position / duration.max(1.0)).clamp(0.0, 1.0) as f32);
np.set_position_secs(position as i32);
np.set_duration_secs(duration as i32);
np.set_elapsed(format_mmss(position).into());
});
if ended {
log::info!("[Cast] track ended (state={state}); auto-advancing");
if let Err(e) = self.next_if_cast().await {
log::warn!("[Cast] auto-advance failed: {e}");
}
}
}
// ---- QConnect coexistence ----------------------------------------------
async fn suspend_qconnect_if_on(&self) {
let Some(qc) = crate::qconnect_service::service() else {
return;
};
if qc.is_running().await {
self.inner.lock().await.qconnect_was_on_before_cast = true;
if let Err(e) = qc.disconnect().await {
log::warn!("[Cast] QConnect suspend failed (continuing): {e}");
}
}
}
async fn restore_qconnect(&self) {
let Some(qc) = crate::qconnect_service::service() else {
return;
};
if let Err(e) = qc.connect().await {
log::warn!("[Cast] QConnect restore failed: {e}");
}
}
// ---- Shutdown (logout / app exit) --------------------------------------
/// Tear down everything: stop the renderer, abort the poll, drop discovery
/// and the media server. Fixes the Tauri logout/exit leaks (#32/#33).
pub async fn shutdown(&self) {
let _ = self.stop_renderer().await;
let mut inner = self.inner.lock().await;
if let Some(task) = inner.poll_task.take() {
task.abort();
}
if let Some(h) = inner.chromecast.take() {
let _ = h.disconnect();
}
if let Some(mut c) = inner.dlna.take() {
let _ = c.disconnect();
}
if let Some(mut disco) = inner.chromecast_discovery.take() {
let _ = disco.stop_discovery();
}
if let Some(mut disco) = inner.dlna_discovery.take() {
let _ = disco.stop_discovery();
}
if let Some(mut server) = inner.media_server.take() {
server.stop();
}
inner.protocol = None;
inner.connected_device_ip = None;
inner.connected_device_name = None;
inner.current_track_id = None;
inner.is_playing = false;
}
// ---- State push to the UI ----------------------------------------------
fn push_devices(&self, chromecast: Vec<DiscoveredDevice>, dlna: Vec<DiscoveredDlnaDevice>) {
let weak = self.window.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
use slint::ComponentHandle;
let cc_count = chromecast.len() as i32;
let dl_count = dlna.len() as i32;
let mut rows: Vec<crate::CastDevice> = Vec::with_capacity(chromecast.len() + dlna.len());
for d in chromecast {
rows.push(crate::CastDevice {
id: d.id.into(),
name: d.name.into(),
ip: d.ip.into(),
protocol: "chromecast".into(),
model: d.model.into(),
can_play: true,
can_set_volume: true,
});
}
for d in dlna {
rows.push(crate::CastDevice {
id: d.id.into(),
name: d.name.into(),
ip: d.ip.into(),
protocol: "dlna".into(),
model: d.model.into(),
can_play: d.has_av_transport,
can_set_volume: d.has_rendering_control,
});
}
let model = std::rc::Rc::new(slint::VecModel::from(rows));
let cs = w.global::<CastState>();
cs.set_devices(model.into());
cs.set_chromecast_count(cc_count);
cs.set_dlna_count(dl_count);
});
}
async fn push_connection_state(&self) {
let (connected, protocol, name, playing) = {
let inner = self.inner.lock().await;
(
inner.protocol.is_some(),
inner.protocol.map(|p| p.as_str().to_string()).unwrap_or_default(),
inner.connected_device_name.clone().unwrap_or_default(),
inner.is_playing,
)
};
let weak = self.window.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
use slint::ComponentHandle;
let cs = w.global::<CastState>();
cs.set_connected(connected);
cs.set_protocol(protocol.clone().into());
cs.set_device_name(name.into());
cs.set_is_playing(playing);
let np = w.global::<NowPlayingState>();
np.set_cast_active(connected);
np.set_cast_protocol(protocol.into());
});
}
async fn push_quality(&self, label: String, detail: String) {
let weak = self.window.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
use slint::ComponentHandle;
let cs = w.global::<CastState>();
cs.set_quality_label(label.into());
cs.set_quality_detail(detail.into());
});
}
}
// ---- Free helpers -----------------------------------------------------------
/// Resolve the on-disk path for a local/ephemeral track id (mirrors
/// `playback::local_track_file_exists` lookup).
fn resolve_local_path(track_id: u64) -> Option<String> {
if crate::ephemeral::is_ephemeral_id(track_id as i64) {
crate::ephemeral::get_track(track_id as i64).map(|row| row.file_path)
} else {
crate::library_db::with_db(|db| db.get_track(track_id as i64))
.flatten()
.map(|row| row.file_path)
}
}
/// Content type for a local file by extension (for the UI label; the served
/// MIME is set by the crate's own map in register_file).
fn content_type_for_local(path: &str) -> String {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase())
.unwrap_or_default();
match ext.as_str() {
"flac" => "audio/flac",
"wav" => "audio/wav",
"m4a" | "alac" | "mp4" => "audio/mp4",
"aiff" | "aif" => "audio/aiff",
"ape" => "audio/x-ape",
"mp3" => "audio/mpeg",
"ogg" | "oga" => "audio/ogg",
"opus" => "audio/opus",
"aac" => "audio/aac",
_ => "application/octet-stream",
}
.to_string()
}
fn media_metadata(track: &QueueTrack) -> MediaMetadata {
MediaMetadata {
title: track.title.clone(),
artist: track.artist.clone(),
album: track.album.clone(),
artwork_url: track.artwork_url.clone(),
duration_secs: Some(track.duration_secs),
}
}
fn dlna_metadata(track: &QueueTrack) -> DlnaMetadata {
DlnaMetadata {
title: track.title.clone(),
artist: track.artist.clone(),
album: track.album.clone(),
artwork_url: track.artwork_url.clone(),
duration_secs: Some(track.duration_secs),
}
}
/// Precise quality string like "192 kHz / 24-bit" from the resolved info.
fn format_quality_detail(q: &qbz_models::StreamQualityInfo) -> String {
match (q.sampling_rate_khz, q.bit_depth) {
(Some(khz), Some(bits)) => format!("{} kHz / {}-bit", trim_khz(khz), bits),
(Some(khz), None) => format!("{} kHz", trim_khz(khz)),
(None, Some(bits)) => format!("{}-bit", bits),
(None, None) => String::new(),
}
}
/// Format seconds as "m:ss" for the now-playing bar elapsed label.
fn format_mmss(secs: f64) -> String {
let total = secs.max(0.0) as u64;
format!("{}:{:02}", total / 60, total % 60)
}
/// Format a kHz value without a trailing ".0" (96.0 -> "96", 44.1 -> "44.1").
fn trim_khz(khz: f64) -> String {
if (khz.fract()).abs() < f64::EPSILON {
format!("{}", khz as i64)
} else {
format!("{:.1}", khz)
}
}
+125
View File
@@ -57,6 +57,7 @@ mod play_history;
mod strip_html;
mod playback;
mod qconnect_engine;
mod cast_service;
mod qconnect_event_sink;
mod qconnect_service;
mod qconnect_transport;
@@ -6583,6 +6584,77 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
});
}
// Cast (Chromecast / DLNA) — picker discovery + connect/disconnect. Distinct
// from QConnect above; routes by source, never QConnect admission.
{
let _cast = cast_service::init_service(app_runtime.clone(), window.as_weak());
let handle = tokio_rt.handle().clone();
window.global::<CastActions>().on_open(move || {
let Some(svc) = cast_service::service() else {
return;
};
handle.spawn(async move {
svc.start_discovery().await;
});
});
}
{
let handle = tokio_rt.handle().clone();
window.global::<CastActions>().on_close(move || {
let Some(svc) = cast_service::service() else {
return;
};
handle.spawn(async move {
svc.stop_discovery().await;
});
});
}
{
let handle = tokio_rt.handle().clone();
window.global::<CastActions>().on_refresh(move || {
let Some(svc) = cast_service::service() else {
return;
};
// start_discovery re-arms the scan window + refresh loop.
handle.spawn(async move {
svc.start_discovery().await;
});
});
}
{
let handle = tokio_rt.handle().clone();
let weak = window.as_weak();
window
.global::<CastActions>()
.on_connect(move |device_id, protocol| {
let Some(svc) = cast_service::service() else {
return;
};
let weak = weak.clone();
let id = device_id.to_string();
let proto = protocol.to_string();
handle.spawn(async move {
if let Err(e) = svc.connect(id, proto).await {
log::warn!("[Cast] connect failed: {e}");
let _ = weak.upgrade_in_event_loop(move |w| {
use slint::ComponentHandle;
w.global::<CastState>().set_error(e.into());
});
}
});
});
}
{
let handle = tokio_rt.handle().clone();
window.global::<CastActions>().on_disconnect(move || {
let Some(svc) = cast_service::service() else {
return;
};
handle.spawn(async move {
svc.disconnect().await;
});
});
}
{
let weak = window.as_weak();
window
@@ -6633,6 +6705,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
// Cast takes priority over QConnect over local.
if let Some(cast) = cast_service::service() {
match cast.toggle_play_if_cast().await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[Cast] toggle_play: {e}");
return;
}
}
}
if let Some(svc) = qconnect_service::service() {
match svc.toggle_remote_renderer_playback_if_active().await {
Ok(true) => return,
@@ -6656,6 +6739,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
if let Some(cast) = cast_service::service() {
match cast.next_if_cast().await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[Cast] next: {e}");
return;
}
}
}
if let Some(svc) = qconnect_service::service() {
match svc.skip_next_if_remote().await {
Ok(true) => return,
@@ -6679,6 +6772,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
if let Some(cast) = cast_service::service() {
match cast.previous_if_cast().await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[Cast] previous: {e}");
return;
}
}
}
if let Some(svc) = qconnect_service::service() {
match svc.skip_previous_if_remote().await {
Ok(true) => return,
@@ -6702,6 +6805,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let runtime = runtime.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
if let Some(cast) = cast_service::service() {
let frac = fraction.clamp(0.0, 1.0) as f64;
let dur = runtime.core().get_playback_state().duration as f64;
match cast.seek_if_cast(frac * dur).await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[Cast] seek: {e}");
return;
}
}
}
if let Some(svc) = qconnect_service::service() {
// Remote API wants absolute position in ms; the bar gives
// a 0..1 fraction. Derive ms from the locally-known
@@ -6736,6 +6851,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
if let Some(cast) = cast_service::service() {
match cast.set_volume_if_cast(fraction.clamp(0.0, 1.0)).await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[Cast] set_volume: {e}");
return;
}
}
}
if let Some(svc) = qconnect_service::service() {
// Remote API wants 0..100; the bar gives a 0..1 fraction.
let volume = (fraction.clamp(0.0, 1.0) * 100.0).round() as i32;
+1 -1
View File
@@ -20,7 +20,7 @@ import { ShellState, MiniPlayerState } from "state.slint";
import { MiniShell } from "miniplayer/MiniShell.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, VisualizerState, ImmersiveState, MiniPlayerState } from "state.slint";
export { HomeState, HomeActions, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, CastState, CastDevice, CastActions, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, VisualizerState, ImmersiveState, MiniPlayerState } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
+46
View File
@@ -3416,6 +3416,52 @@ export global QconnectDevState {
callback set-active(int); // renderer-id; the local id = "Play here"
}
// Cast (Chromecast / DLNA) — distinct from Qobuz Connect above. A discovered
// renderer for the cast picker. Modeled as a tagged record (the `protocol`
// field) so Chromecast and DLNA share one list; DLNA carries capability flags.
export struct CastDevice {
id: string,
name: string,
ip: string,
protocol: string, // "chromecast" | "dlna"
model: string,
can-play: bool, // DLNA has-av-transport (Chromecast: true)
can-set-volume: bool, // DLNA has-rendering-control (Chromecast: true)
}
// Cast picker + connection + transport-mirror state. Populated by
// `cast_service.rs`; read by CastPicker and AudioOutputBadges. There is NO
// error/connecting sub-state in the Tauri original beyond a raw error string.
export global CastState {
in-out property <bool> picker-open: false;
in-out property <string> selected-tab: "chromecast"; // "chromecast" | "dlna"
in property <bool> scanning: false; // 15s scan-spinner window
in property <[CastDevice]> devices: [];
in property <int> chromecast-count: 0;
in property <int> dlna-count: 0;
// Connection.
in property <bool> connected: false;
in property <string> protocol; // "chromecast" | "dlna" | ""
in property <string> device-name; // connected device label
// Transport mirror (cast owns transport while connected).
in property <bool> is-playing: false;
in property <float> position-secs: 0;
in property <float> duration-secs: 0;
// Delivered quality (D3) — surfaced in the badge tooltip / picker banner.
in property <string> quality-label; // coarse, e.g. "FLAC 24-bit/>96kHz"
in property <string> quality-detail; // precise, e.g. "192 kHz / 24-bit"
// Raw last-error for the picker error state (empty = none).
in property <string> error;
}
export global CastActions {
callback open(); // open picker + start discovery
callback close(); // close picker + stop discovery
callback refresh(); // re-arm discovery scan
callback connect(string, string); // device-id, protocol
callback disconnect();
}
// Single global hover-tooltip channel. Each hoverable control writes its text
// + absolute anchor (window coords: centre-x and top-y of the control) here on
// hover; one TooltipOverlay (mounted last, on top of everything) renders the