Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
230 lines
11 KiB
Plaintext
230 lines
11 KiB
Plaintext
/// 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<Mutex<PlaybackSnapshot>>,
|
|
}
|
|
|
|
static PROCESS: OnceLock<ProcessPlayer> = 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<Option<tauri::AppHandle>> = 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<String>) -> &'static ProcessPlayer {
|
|
let (process, _created) = process_player_created(device);
|
|
process
|
|
}
|
|
|
|
fn process_player_created(device: Option<String>) -> (&'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<tauri::AppHandle> {
|
|
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<S: Serialize + Clone>(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<PlayerEvent>, snapshot: Arc<Mutex<PlaybackSnapshot>>) {
|
|
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");
|
|
}
|