Files
SoulStream/handoff/rounds/ins-r51-playback.txt
T
KeelrLeek 9f0601027c android: the handoff — the pending rounds, the pipeline, and what is unverified
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.
2026-09-14 07:57:41 -05:00

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");
}