fix: resume playback after iOS audio route changes

close #1185
This commit is contained in:
edde746
2026-05-29 11:04:27 +02:00
parent 4b477a4fe3
commit 5e5702c961
5 changed files with 86 additions and 4 deletions
@@ -103,6 +103,11 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
return;
}
if (_isAppleAudioSessionEvent(event)) {
unawaited(_handleAppleAudioSessionEvent(event));
return;
}
if (activePlayer == null && event is! NextTrackEvent && event is! PreviousTrackEvent) return;
if (event is PlayEvent) {
@@ -190,6 +195,10 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
}
void _onPlayingStateChanged(bool isPlaying) {
if (!isPlaying) {
_lastPlaybackPauseAt = DateTime.now();
}
if (isPlaying && _mediaControlsSuspendedForTvBackground) {
appLogger.w('Playback started while Android TV background media controls are suspended; pausing');
Sentry.addBreadcrumb(
@@ -233,6 +242,77 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
}
}
bool _isAppleAudioSessionEvent(Object event) =>
event is AudioInterruptionBeganEvent ||
event is AudioInterruptionEndedEvent ||
event is AudioRouteOldDeviceUnavailableEvent ||
event is AudioRouteNewDeviceAvailableEvent;
Future<void> _handleAppleAudioSessionEvent(Object event) async {
if (!Platform.isIOS || PlatformDetector.isTV()) return;
final currentPlayer = player;
if (!mounted || currentPlayer == null || !_isPlayerInitialized) return;
if (event is AudioInterruptionBeganEvent) {
await _pauseForAppleAudioSessionEvent(currentPlayer, 'interruption began');
} else if (event is AudioRouteOldDeviceUnavailableEvent) {
await _pauseForAppleAudioSessionEvent(currentPlayer, 'private audio route disconnected');
} else if (event is AudioInterruptionEndedEvent) {
if (event.shouldResume) {
await _resumeAfterAppleAudioSessionEvent(currentPlayer, 'interruption ended');
} else {
_resumeAfterAppleAudioSessionPause = false;
}
} else if (event is AudioRouteNewDeviceAvailableEvent) {
await _resumeAfterAppleAudioSessionEvent(currentPlayer, 'private audio route connected');
}
}
Future<void> _pauseForAppleAudioSessionEvent(Player currentPlayer, String reason) async {
final wasPlayingBeforeEvent = currentPlayer.state.isActive || _wasRecentlyPaused();
if (wasPlayingBeforeEvent) {
_resumeAfterAppleAudioSessionPause = true;
}
try {
await currentPlayer.pause();
appLogger.d('Video paused after Apple audio session $reason');
} catch (e) {
appLogger.w('Failed to pause after Apple audio session $reason', error: e);
} finally {
_updateMediaControlsPlaybackState();
}
}
bool _wasRecentlyPaused() {
final pauseAt = _lastPlaybackPauseAt;
if (pauseAt == null) return false;
return DateTime.now().difference(pauseAt) < const Duration(seconds: 1);
}
Future<void> _resumeAfterAppleAudioSessionEvent(Player expectedPlayer, String reason) async {
if (!_resumeAfterAppleAudioSessionPause) return;
_resumeAfterAppleAudioSessionPause = false;
if (!mounted || player != expectedPlayer || !_isPlayerInitialized) return;
if (WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed) {
appLogger.d('Skipped Apple audio session resume after $reason because app is not resumed');
return;
}
if (expectedPlayer.state.isActive) return;
try {
await expectedPlayer.play();
_wasPlayingBeforeInactive = false;
appLogger.d('Video resumed after Apple audio session $reason');
} catch (e) {
appLogger.w('Failed to resume after Apple audio session $reason', error: e);
} finally {
_updateMediaControlsPlaybackState();
}
}
/// Force mpv to reconnect its HTTP stream by seeking to the current position.
/// This bypasses ffmpeg's exponential reconnect backoff when the app detects
/// that network connectivity has been restored.
+2
View File
@@ -370,6 +370,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
bool _hiddenForBackground = false;
bool _mediaControlsSuspendedForTvBackground = false;
bool _resumeFromSuspendedMediaControlOnForeground = false;
bool _resumeAfterAppleAudioSessionPause = false;
DateTime? _lastPlaybackPauseAt;
bool _autoPipEnabled = false;
bool _androidAutoPipTransitionInFlight = false;
bool _pipFiltersPrepared = false;
+1 -1
View File
@@ -15,7 +15,7 @@ import '../utils/app_logger.dart';
/// - Position update throttling to prevent excessive API calls
class MediaControlsManager {
/// Stream of control events from OS media controls
Stream<dynamic> get controlEvents => OsMediaControls.controlEvents;
Stream<MediaControlEvent> get controlEvents => OsMediaControls.controlEvents;
/// Throttled playback state update (1 second interval, leading + trailing)
late final Throttle _throttledUpdate;
+2 -2
View File
@@ -768,8 +768,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "5ddd27c"
resolved-ref: "5ddd27c2acacca31060d288db1371a870602cf46"
ref: d5c1885
resolved-ref: d5c18857ada19896cf456b832c143da435a7155d
url: "https://github.com/edde746/os-media-controls"
source: git
version: "0.2.1"
+1 -1
View File
@@ -35,7 +35,7 @@ dependencies:
os_media_controls:
git:
url: https://github.com/edde746/os-media-controls
ref: 5ddd27c
ref: d5c1885
rate_limiter: ^1.0.0
wakelock_plus:
git: