diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index eecf3f8d..a4ab8be8 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -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 _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 _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 _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. diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index a2b5b38b..97c87dc6 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -370,6 +370,8 @@ class VideoPlayerScreenState extends State 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; diff --git a/lib/services/media_controls_manager.dart b/lib/services/media_controls_manager.dart index d16d43f0..655bfef6 100644 --- a/lib/services/media_controls_manager.dart +++ b/lib/services/media_controls_manager.dart @@ -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 get controlEvents => OsMediaControls.controlEvents; + Stream get controlEvents => OsMediaControls.controlEvents; /// Throttled playback state update (1 second interval, leading + trailing) late final Throttle _throttledUpdate; diff --git a/pubspec.lock b/pubspec.lock index 3278f4f1..4676c929 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml index 9067c253..5ff1fe1d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: