/// The ONE player in this process, and the ONE pump that reads it. /// /// Round fifty-one made this a singleton, and the reason is not tidiness: /// `Player::spawn_with_device` opens its cpal output stream EAGERLY, at the /// top of `audio_thread`, so two `Player`s is two live AAudio output /// streams — on Android each asking for an 8192-frame buffer — and two /// decoders feeding one queue. The symptom would be a doubled, phasing /// track on Kyle's own phone with his own music, which is the worst thing /// this whole Android Auto plan could produce. /// /// Whoever gets here first builds it: `playback::init` on a normal launch, /// or `headless::start` in a process Android Auto started with no Activity /// in it at all. `Player` already derives `Clone` and is nothing but a /// command-channel handle, so `PlayerState.player` is a clone of the same /// audio thread and not one line of the twenty-odd `players.player.…` call /// sites had to change. /// /// The pump is part of the singleton for a duller reason: it owns the event /// `Receiver`, and a `Receiver` cannot be cloned. pub struct ProcessPlayer { pub player: Player, /// The transport mirror the pump writes and `player_state` reads. Shared /// rather than owned by `PlayerState`, because it exists before there is /// a `PlayerState` to own it. pub snapshot: Arc>, } static PROCESS: OnceLock = OnceLock::new(); /// The app handle, once Tauri has run. `None` means a service-started /// process with no Activity — no events to emit, no OS card to drive, and /// the headless fallback is allowed to be the queue. static APP: Mutex> = Mutex::new(None); /// The process player, created on the first ask. `device` is honoured only /// by that first ask; a later adopter re-applies its own preference. pub fn process_player(device: Option) -> &'static ProcessPlayer { let (process, _created) = process_player_created(device); process } fn process_player_created(device: Option) -> (&'static ProcessPlayer, bool) { let mut created = false; let process = PROCESS.get_or_init(|| { created = true; let (player, events) = Player::spawn_with_device(device); let snapshot = Arc::new(Mutex::new(PlaybackSnapshot { status: "idle".into(), ..Default::default() })); spawn_pump(events, snapshot.clone()); ProcessPlayer { player, snapshot } }); (process, created) } /// Is there a webview in this process? True from `init` onward, for the life /// of the process — a WebView destroyed from Recents is deliberately NOT /// detected here, because no signal for it has ever been read back from this /// phone and guessing would hand the queue to two drivers. pub fn has_app() -> bool { APP.lock().map(|a| a.is_some()).unwrap_or(false) } fn app_handle() -> Option { APP.lock().ok().and_then(|a| a.clone()) } /// Emit to the webview if there is one. `false` means there was not. pub fn emit_to_app(event: &str, payload: S) -> bool { match app_handle() { Some(handle) => handle.emit(event, payload).is_ok(), None => false, } } /// The transport as of the audio thread's last event, readable before there /// is any `PlayerState` — which is what the headless path needs and what /// `player_state` has always answered. pub fn snapshot() -> PlaybackSnapshot { match PROCESS.get() { Some(process) => process .snapshot .lock() .map(|s| s.clone()) .unwrap_or_default(), None => PlaybackSnapshot::default(), } } /// Create the audio thread and forward its events to the webview. pub fn init(app: &tauri::AppHandle) { let output_device = crate::config::load(app).audio_output_device; let (process, created) = process_player_created(output_device.clone()); if let Ok(mut slot) = APP.lock() { *slot = Some(app.clone()); } if created { crate::headless::log("player created"); } else { // ADOPTED a player the headless path built. It had no `AppHandle` // to read `audio_output_device` with, so the preference is applied // now. On Android that field is always None and this never runs; // on a desktop it is the difference between following the user's // routing and following the system default. if output_device.is_some() { if let Err(e) = process.player.set_output_device(output_device) { log::warn!("could not re-apply the configured output device: {e}"); } } crate::headless::log("player adopted"); } // BEFORE anything the webview does. The fallback queue and // `armPreload()` must never both be appending to one rodio sink, and // this is the earliest point in the Activity's life at which the // handover can be made. It stops the DRIVER, not the music: the rows // survive for `headless_state()` to hand over a second later. crate::headless::stand_down(); app.manage(PlayerState { player: process.player.clone(), streams: Mutex::new(HashMap::new()), stream_caches: Mutex::new(HashMap::new()), waveforms: Mutex::new(WaveformLru::new(WAVEFORM_CACHE_MAX_ENTRIES)), progressive_waveforms: Mutex::new(WaveformLru::new(PROGRESSIVE_WAVEFORM_CACHE_MAX_ENTRIES)), waveform_jobs: Mutex::new(HashMap::new()), preloads: Mutex::new(HashMap::new()), deferred_saves: Mutex::new(HashMap::new()), last_transport: process.snapshot.clone(), }); } /// The one event pump. Spawned by the singleton's initializer, so it exists /// whether or not Tauri ever runs in this process; the Tauri half of its /// body is skipped while there is no app handle. fn spawn_pump(events: std::sync::mpsc::Receiver, snapshot: Arc>) { std::thread::Builder::new() .name("player-events".into()) .spawn(move || { let mut last_position_emit = Instant::now() - UI_POSITION_INTERVAL; for ev in events { let ui = match ev { PlayerEvent::Buffering => { last_position_emit = Instant::now() - UI_POSITION_INTERVAL; PlayerUiEvent::Buffering } PlayerEvent::Playing => { last_position_emit = Instant::now() - UI_POSITION_INTERVAL; PlayerUiEvent::Playing } PlayerEvent::Paused => PlayerUiEvent::Paused, PlayerEvent::Stopped => PlayerUiEvent::Stopped, PlayerEvent::TrackEnded { gapless } => { last_position_emit = Instant::now() - UI_POSITION_INTERVAL; PlayerUiEvent::TrackEnded { gapless } } PlayerEvent::Position { secs, duration } => { let now = Instant::now(); if now.duration_since(last_position_emit) < UI_POSITION_INTERVAL { continue; } last_position_emit = now; PlayerUiEvent::Position { secs, duration } } PlayerEvent::Error(message) => PlayerUiEvent::Error { message }, }; // Mirror the transport before emitting, so a webview that // reloads mid-track can ask what it missed. TrackEnded is // deliberately not recorded: the frontend owns the queue and // decides what follows, and the next Buffering/Stopped says // what actually happened. // The OS media card rides this same mirror (see smtc.rs). The // snapshot IS the transport the UI is about to be told about, // so the card cannot drift from the window, and driving it // from here costs no IPC at all. Copied out rather than used // under the lock: the card's updates are cross-process calls // and have no business holding the transport mutex. let mut card = None; if let Ok(mut last) = snapshot.lock() { match &ui { PlayerUiEvent::Buffering => { last.status = "buffering".into(); last.position = 0.0; } PlayerUiEvent::Playing => last.status = "playing".into(), PlayerUiEvent::Paused => last.status = "paused".into(), PlayerUiEvent::Stopped | PlayerUiEvent::Error { .. } => { *last = PlaybackSnapshot { status: "idle".into(), ..Default::default() }; } PlayerUiEvent::Position { secs, duration } => { last.position = *secs; if duration.is_some() { last.duration = *duration; } } PlayerUiEvent::TrackEnded { .. } => {} } card = Some((last.status.clone(), last.position, last.duration)); } // The headless fallback, which is a no-op unless IT is the // driver. A track that ends with nobody watching is the // whole claim of this round: the album keeps going. match &ui { PlayerUiEvent::TrackEnded { .. } => crate::headless::on_track_ended(), PlayerUiEvent::Buffering => crate::headless::on_transport("buffering"), PlayerUiEvent::Playing => crate::headless::on_transport("playing"), PlayerUiEvent::Paused => crate::headless::on_transport("paused"), PlayerUiEvent::Stopped | PlayerUiEvent::Error { .. } => { crate::headless::on_transport("idle") } PlayerUiEvent::Position { .. } => {} } let handle = app_handle(); if let (Some(handle), Some((status, position, duration))) = (&handle, card) { crate::smtc::set_status(handle, &status); crate::smtc::set_timeline(handle, position, duration); // The taskbar preview's middle button wears play or pause // off the same truth. crate::thumbbar::set_playing(handle, status == "playing"); } if let Some(handle) = handle { let _ = handle.emit("soulseek:player", ui); } } }) .expect("spawn player event forwarder"); }