From d1313eb8e9a9a31def5feb487c269f56ef837011 Mon Sep 17 00:00:00 2001 From: "Michael A. Davis" Date: Sun, 16 Aug 2026 15:45:10 -0500 Subject: [PATCH 1/2] fix(ios): resume podcast on transient audio interruption, preserve playback speed on play - Auto-resume playback when AVAudioSession reports an interruption ended with shouldResume (e.g. a Siri or notification readout), instead of leaving the episode paused with no way to resume it if the screen is off. Real phone calls don't set shouldResume, so they're unaffected. - AVPlayer.play() always resets rate to 1.0, discarding a user-selected playback speed on episode start, lock-screen play, and interruption resume. Track the desired rate and reassert it after every play() call. Fixes the iOS side of madeofpendletonwool/PinePods#882. --- .../Runner/Audio/PinepodsAudioPlayer.swift | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/mobile/ios/Runner/Audio/PinepodsAudioPlayer.swift b/mobile/ios/Runner/Audio/PinepodsAudioPlayer.swift index 0bff1589..1d4f2649 100644 --- a/mobile/ios/Runner/Audio/PinepodsAudioPlayer.swift +++ b/mobile/ios/Runner/Audio/PinepodsAudioPlayer.swift @@ -29,6 +29,9 @@ class PinepodsAudioPlayer: NSObject { private var skipSilenceEnabled = false private var skipSegments: [(start: Double, end: Double)] = [] + // AVPlayer.play() always resets rate to 1.0, so we track the desired rate here and reassert it after play(). + private var currentPlaybackRate: Float = 1.0 + init(eventSink: FlutterEventSink?) { self.eventSink = eventSink super.init() @@ -282,8 +285,16 @@ class PinepodsAudioPlayer: NSObject { } let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) if options.contains(.shouldResume) { - NSLog("[PinepodsAudioPlayer] Audio interruption ended - should resume available") - // Don't auto-resume for podcasts - let user decide + // shouldResume only fires for transient interruptions (e.g. Siri), not phone calls. + NSLog("[PinepodsAudioPlayer] Audio interruption ended - resuming playback") + do { + try AVAudioSession.sharedInstance().setActive(true) + } catch { + NSLog("[PinepodsAudioPlayer] Failed to reactivate audio session after interruption: \(error)") + } + play() + } else { + NSLog("[PinepodsAudioPlayer] Audio interruption ended - resume not recommended by system") } @unknown default: @@ -495,22 +506,20 @@ class PinepodsAudioPlayer: NSObject { if finished { NSLog("[PinepodsAudioPlayer] Seeked to start position: \(startPosition)ms") } - self?.player?.play() - // Update now playing info again after playback starts - self?.updateNowPlayingPlaybackInfo() + self?.play() } } else { - player?.play() - // Update now playing info after playback starts - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.updateNowPlayingPlaybackInfo() - } + play() } } func play() { - NSLog("[PinepodsAudioPlayer] play") + NSLog("[PinepodsAudioPlayer] play, reasserting rate \(currentPlaybackRate)") player?.play() + // play() resets rate to 1.0, so reassert the desired rate. + if currentPlaybackRate != 1.0 { + player?.rate = currentPlaybackRate + } // Slight delay to allow rate to update before refreshing Now Playing DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in self?.updateNowPlayingPlaybackInfo() @@ -562,7 +571,11 @@ class PinepodsAudioPlayer: NSObject { func setPlaybackSpeed(_ speed: Float) { NSLog("[PinepodsAudioPlayer] setPlaybackSpeed: \(speed)") - player?.rate = speed + currentPlaybackRate = speed + // Setting a nonzero rate while paused would start playback, so only apply it if already playing. + if player?.rate != 0 { + player?.rate = speed + } updateNowPlayingPlaybackInfo() } From 216fdf563d2f97956c8098bfc1b9ffe563cbf521 Mon Sep 17 00:00:00 2001 From: "Michael A. Davis" Date: Tue, 18 Aug 2026 10:56:14 -0500 Subject: [PATCH 2/2] fix: stop server's non-customized playback speed from overriding local speed Root cause of both #882 (iOS) and #963 (Android): getPlayEpisodeDetails parsed the server's playback_speed field but dropped playback_speed_customized, which the Rust backend sends to distinguish a real per-podcast override from it echoing back the account-wide default (Users.playbackspeed). The mobile speed selector only writes to local SharedPreferences and never syncs to that server column, so for anyone who hasn't set it via the web UI it sits at 1.0. playPinepodsEpisode() then unconditionally called setPlaybackSpeed with that value right after starting playback, stomping whatever speed playEpisode() had just applied locally, on every episode start, on both platforms. That's why tapping the in-app speed +/- buttons after playback started seemed to fix it: it's the only path with no follow-up override. Add playbackSpeedCustomized to PlayEpisodeDetails and only apply the server's speed when it's an actual customization, in both the main playback path and the local-download background enrichment path. This doesn't replace the AVPlayer.play()-resets-rate fix already applied for iOS. That's a separate, contributing bug. --- .../lib/services/pinepods/pinepods_audio_service.dart | 10 +++++++--- mobile/lib/services/pinepods/pinepods_service.dart | 4 ++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/mobile/lib/services/pinepods/pinepods_audio_service.dart b/mobile/lib/services/pinepods/pinepods_audio_service.dart index e21b77b4..82b9e106 100644 --- a/mobile/lib/services/pinepods/pinepods_audio_service.dart +++ b/mobile/lib/services/pinepods/pinepods_audio_service.dart @@ -236,8 +236,10 @@ class PinepodsAudioService { // Start playing with the existing audio service await _audioPlayerService.playEpisode(episode: episode, resume: resume); - // Apply server-side speed after episode starts — overrides any locally stored speed - await _audioPlayerService.setPlaybackSpeed(playDetails.playbackSpeed); + // Only apply the server speed if it's a real per-podcast override, not the echoed-back default (#882, #963). + if (playDetails.playbackSpeedCustomized) { + await _audioPlayerService.setPlaybackSpeed(playDetails.playbackSpeed); + } // Handle skip intro if enabled and episode just started if (playDetails.startSkip > 0 && !resume) { @@ -323,7 +325,9 @@ class PinepodsAudioService { .getPlayEpisodeDetails(userId, podcastId, pinepodsEpisode.isYoutube); if (_currentEpisodeId == episodeId) { - await _audioPlayerService.setPlaybackSpeed(playDetails.playbackSpeed); + if (playDetails.playbackSpeedCustomized) { + await _audioPlayerService.setPlaybackSpeed(playDetails.playbackSpeed); + } if (playDetails.startSkip > 0 && !resume) { await _audioPlayerService.seek(position: playDetails.startSkip); diff --git a/mobile/lib/services/pinepods/pinepods_service.dart b/mobile/lib/services/pinepods/pinepods_service.dart index 3ff8e0c7..7c3e1a27 100644 --- a/mobile/lib/services/pinepods/pinepods_service.dart +++ b/mobile/lib/services/pinepods/pinepods_service.dart @@ -658,6 +658,7 @@ class PinepodsService { playbackSpeed: (data['playback_speed'] as num?)?.toDouble() ?? 1.0, startSkip: data['start_skip'] ?? 0, endSkip: data['end_skip'] ?? 0, + playbackSpeedCustomized: data['playback_speed_customized'] as bool? ?? false, ); } return PlayEpisodeDetails(playbackSpeed: 1.0, startSkip: 0, endSkip: 0); @@ -2792,11 +2793,14 @@ class PlayEpisodeDetails { final double playbackSpeed; final int startSkip; final int endSkip; + // True only for a genuine per-podcast override, not the echoed-back account default. + final bool playbackSpeedCustomized; PlayEpisodeDetails({ required this.playbackSpeed, required this.startSkip, required this.endSkip, + this.playbackSpeedCustomized = false, }); }