Fix audio session stealing from other apps on launch

Deferred audio session configuration from AudioPlayer.init() to first
play. Setting .playback category on init interrupted podcasts and other
audio before the user even tapped a song.

- configureAudioSession() removed from init, added to crossfade path
  and playLocalWithEngine (playWithAVPlayer already had it)
- resumeVisTimers: setActive(true) moved below guard isPlaying so
  foregrounding the app without active playback leaves other audio alone
- All 4 setActive(true) sites now gated behind actual playback or
  system interruption recovery
This commit is contained in:
Dallas Groot 2026-04-14 00:57:23 -07:00
parent 842cb6353b
commit 2b81032455

View file

@ -134,7 +134,9 @@ class AudioPlayer: NSObject, ObservableObject {
vDSP_hann_window(&fftWindow, 1024, Int32(vDSP_HANN_NORM))
super.init()
configureAudioSession()
// Audio session is configured lazily on first play not here.
// Setting .playback category on init interrupts whatever audio is
// already playing (podcasts, other music apps).
setupRemoteControls()
#if os(iOS)
@ -245,15 +247,6 @@ class AudioPlayer: NSObject, ObservableObject {
}
private func resumeVisTimers() {
// Re-activate the audio session another app may have taken it while we
// were in the background (e.g. a game or Spotify). Without this, player.play()
// silently fails and isPlaying shows true with no audio output.
do {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
alog("Foreground: session reactivation failed: \(error)")
}
// Pick up any widget commands that arrived while the process was suspended.
// Darwin notifications can't wake suspended processes, so commands written
// by widget intents sit in App Group UserDefaults until we check here.
@ -278,9 +271,19 @@ class AudioPlayer: NSObject, ObservableObject {
if isUsingCrossfade {
SmartCrossfadeManager.shared.resumeFromBackground()
}
// Only restart vis timers if actually playing
// Only restart vis timers + reclaim audio session if actually playing.
// If nothing is playing, don't touch the session let podcasts/other apps keep it.
guard isPlaying else { return }
// Re-activate the audio session another app may have taken it while we
// were in the background (e.g. a game or Spotify). Without this, player.play()
// silently fails and isPlaying shows true with no audio output.
do {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
alog("Foreground: session reactivation failed: \(error)")
}
// Restart Now Playing sync timer keeps Lock Screen / Dynamic Island
// seek bar accurate. Created in playWithAVPlayer but never restarted
// after background cycle; without this the system seek bar drifts.
@ -414,6 +417,10 @@ class AudioPlayer: NSObject, ObservableObject {
stopAll()
isUsingCrossfade = true
// Claim audio session deferred from init() to first play
configureAudioSession()
activateAudioSession()
// Wire callbacks
crossfade.timeUpdate = { [weak self] time, dur in
self?.currentTime = time
@ -886,6 +893,7 @@ class AudioPlayer: NSObject, ObservableObject {
private func playLocalWithEngine(_ url: URL) {
#if os(iOS)
alog("Engine path: \(url.lastPathComponent)")
configureAudioSession()
activateAudioSession()
stopAll()