Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

### Fixed

- **PortAudio Device-Switch Recovery (macOS/AirPods)**: Added recovery for recoverable PortAudio backend panics when the output device changes (for example, AirPods connect/switch events), reducing playback interruptions and preventing app crashes.

## [0.37.0] - 2026-02-27

### Added
Expand Down
2 changes: 0 additions & 2 deletions src/infra/network/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,6 @@ impl PlaybackNetwork for Network {
let native_name = p.device_name().to_lowercase();
c.device.name.to_lowercase() == native_name
});
#[cfg(not(feature = "streaming"))]
let is_native_device = false;

#[cfg(feature = "streaming")]
if is_native_device && app.native_device_id.is_none() {
Expand Down
125 changes: 105 additions & 20 deletions src/infra/player/streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,119 @@ use librespot_playback::{
mixer::{softmixer::SoftMixer, Mixer, MixerConfig},
player::{Player, PlayerEventChannel},
};
use log::info;
use log::{error, info, warn};
use std::any::Any;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{timeout, Duration};

#[derive(Default)]
struct NullSink;
struct RecoveringSink {
inner: Option<Box<dyn audio_backend::Sink>>,
make_sink: Box<dyn Fn() -> Box<dyn audio_backend::Sink>>,
}

impl RecoveringSink {
fn new<F>(make_sink: F) -> Self
where
F: Fn() -> Box<dyn audio_backend::Sink> + 'static,
{
Self {
inner: None,
make_sink: Box::new(make_sink),
}
}

fn payload_to_string(payload: Box<dyn Any + Send>) -> String {
match payload.downcast::<String>() {
Ok(s) => *s,
Err(payload) => match payload.downcast::<&'static str>() {
Ok(s) => s.to_string(),
Err(_) => "unknown panic payload".to_string(),
},
}
}

fn panic_to_sink_error(
context: &'static str,
payload: Box<dyn Any + Send>,
) -> audio_backend::SinkError {
let msg = Self::payload_to_string(payload);
audio_backend::SinkError::StateChange(format!("Audio backend panic in {context}: {msg}"))
}

fn create_sink(&mut self) -> audio_backend::SinkResult<()> {
if self.inner.is_some() {
return Ok(());
}

impl audio_backend::Open for NullSink {
fn open(_: Option<String>, _: AudioFormat) -> Self {
Self
let make_sink = &self.make_sink;
match catch_unwind(AssertUnwindSafe(make_sink)) {
Ok(sink) => {
self.inner = Some(sink);
Ok(())
}
Err(payload) => {
let err = Self::panic_to_sink_error("open", payload);
error!("{err}");
Err(err)
}
}
}

fn with_inner<T, F>(&mut self, context: &'static str, op: F) -> audio_backend::SinkResult<T>
where
F: FnOnce(&mut dyn audio_backend::Sink) -> audio_backend::SinkResult<T>,
{
self.create_sink()?;

let Some(sink) = self.inner.as_mut() else {
return Err(audio_backend::SinkError::NotConnected(
"Audio sink unavailable".to_string(),
));
};

match catch_unwind(AssertUnwindSafe(|| op(sink.as_mut()))) {
Ok(Ok(value)) => Ok(value),
Ok(Err(err)) => {
warn!("Audio backend {context} error: {err}");
self.inner = None;
Err(err)
}
Err(payload) => {
let err = Self::panic_to_sink_error(context, payload);
error!("{err}");
self.inner = None;
Err(err)
}
}
}
}

impl audio_backend::Sink for NullSink {
fn write(&mut self, _: AudioPacket, _: &mut Converter) -> audio_backend::SinkResult<()> {
impl audio_backend::Sink for RecoveringSink {
fn start(&mut self) -> audio_backend::SinkResult<()> {
self.with_inner("start", |sink| sink.start())
}

fn stop(&mut self) -> audio_backend::SinkResult<()> {
if self.inner.is_none() {
return Ok(());
}

// Avoid process exits in librespot when sink.stop() errors.
let _ = self.with_inner("stop", |sink| sink.stop());
self.inner = None;
Ok(())
}

fn write(
&mut self,
packet: AudioPacket,
converter: &mut Converter,
) -> audio_backend::SinkResult<()> {
self.with_inner("write", |sink| sink.write(packet, converter))
}
}

/// OAuth scopes required for streaming (based on spotify-player)
Expand Down Expand Up @@ -260,18 +354,9 @@ impl StreamingPlayer {
session.clone(),
mixer.get_soft_volume(),
move || {
let result =
std::panic::catch_unwind(|| backend(requested_device.clone(), AudioFormat::default()));
match result {
Ok(sink) => sink,
Err(_) => {
eprintln!(
"Failed to initialize audio output backend; falling back to a null sink (no audio). \
Set SPOTATUI_STREAMING_AUDIO_DEVICE to select an output device, or SPOTATUI_STREAMING_AUDIO_BACKEND to select a backend."
);
Box::new(NullSink)
}
}
Box::new(RecoveringSink::new(move || {
backend(requested_device.clone(), AudioFormat::default())
}))
},
);

Expand Down
19 changes: 18 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ mod tui;
use crate::core::app::{self, ActiveBlock, App, RouteId};
use crate::core::config::{ClientConfig, NCSPOT_CLIENT_ID};
use crate::core::user_config::{UserConfig, UserConfigPaths};
#[cfg(any(feature = "audio-viz", feature = "audio-viz-cpal"))]
use crate::infra::audio;
#[cfg(feature = "discord-rpc")]
use crate::infra::discord_rpc;
Expand Down Expand Up @@ -594,6 +595,18 @@ fn setup_logging() -> anyhow::Result<()> {
fn install_panic_hook() {
let default_hook = panic::take_hook();
panic::set_hook(Box::new(move |info| {
let is_portaudio_panic = info
.location()
.map(|location| location.file().contains("audio_backend/portaudio.rs"))
.unwrap_or(false);

if is_portaudio_panic {
eprintln!(
"Recoverable audio backend panic detected. Playback may pause while the output device changes."
);
return;
}

ratatui::restore();
let panic_log_path = dirs::home_dir().map(|home| {
home
Expand Down Expand Up @@ -861,6 +874,7 @@ of the app. Beware that this comes at a CPU cost!",
}

let mut spotify = None;
#[cfg(feature = "streaming")]
let mut selected_redirect_uri = client_config.get_redirect_uri();
let mut last_auth_error = None;

Expand All @@ -883,7 +897,10 @@ of the app. Beware that this comes at a CPU cost!",
info!("Using fallback client ID {}", client_id);
}
client_config.client_id = client_id.clone();
selected_redirect_uri = redirect_uri;
#[cfg(feature = "streaming")]
{
selected_redirect_uri = redirect_uri;
}
spotify = Some(candidate);
break;
}
Expand Down
Loading