@@ -103,6 +103,11 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_isAppleAudioSessionEvent(event)) {
|
||||||
|
unawaited(_handleAppleAudioSessionEvent(event));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (activePlayer == null && event is! NextTrackEvent && event is! PreviousTrackEvent) return;
|
if (activePlayer == null && event is! NextTrackEvent && event is! PreviousTrackEvent) return;
|
||||||
|
|
||||||
if (event is PlayEvent) {
|
if (event is PlayEvent) {
|
||||||
@@ -190,6 +195,10 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onPlayingStateChanged(bool isPlaying) {
|
void _onPlayingStateChanged(bool isPlaying) {
|
||||||
|
if (!isPlaying) {
|
||||||
|
_lastPlaybackPauseAt = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
if (isPlaying && _mediaControlsSuspendedForTvBackground) {
|
if (isPlaying && _mediaControlsSuspendedForTvBackground) {
|
||||||
appLogger.w('Playback started while Android TV background media controls are suspended; pausing');
|
appLogger.w('Playback started while Android TV background media controls are suspended; pausing');
|
||||||
Sentry.addBreadcrumb(
|
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.
|
/// Force mpv to reconnect its HTTP stream by seeking to the current position.
|
||||||
/// This bypasses ffmpeg's exponential reconnect backoff when the app detects
|
/// This bypasses ffmpeg's exponential reconnect backoff when the app detects
|
||||||
/// that network connectivity has been restored.
|
/// that network connectivity has been restored.
|
||||||
|
|||||||
@@ -370,6 +370,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
bool _hiddenForBackground = false;
|
bool _hiddenForBackground = false;
|
||||||
bool _mediaControlsSuspendedForTvBackground = false;
|
bool _mediaControlsSuspendedForTvBackground = false;
|
||||||
bool _resumeFromSuspendedMediaControlOnForeground = false;
|
bool _resumeFromSuspendedMediaControlOnForeground = false;
|
||||||
|
bool _resumeAfterAppleAudioSessionPause = false;
|
||||||
|
DateTime? _lastPlaybackPauseAt;
|
||||||
bool _autoPipEnabled = false;
|
bool _autoPipEnabled = false;
|
||||||
bool _androidAutoPipTransitionInFlight = false;
|
bool _androidAutoPipTransitionInFlight = false;
|
||||||
bool _pipFiltersPrepared = false;
|
bool _pipFiltersPrepared = false;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import '../utils/app_logger.dart';
|
|||||||
/// - Position update throttling to prevent excessive API calls
|
/// - Position update throttling to prevent excessive API calls
|
||||||
class MediaControlsManager {
|
class MediaControlsManager {
|
||||||
/// Stream of control events from OS media controls
|
/// 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)
|
/// Throttled playback state update (1 second interval, leading + trailing)
|
||||||
late final Throttle _throttledUpdate;
|
late final Throttle _throttledUpdate;
|
||||||
|
|||||||
+2
-2
@@ -768,8 +768,8 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: "5ddd27c"
|
ref: d5c1885
|
||||||
resolved-ref: "5ddd27c2acacca31060d288db1371a870602cf46"
|
resolved-ref: d5c18857ada19896cf456b832c143da435a7155d
|
||||||
url: "https://github.com/edde746/os-media-controls"
|
url: "https://github.com/edde746/os-media-controls"
|
||||||
source: git
|
source: git
|
||||||
version: "0.2.1"
|
version: "0.2.1"
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ dependencies:
|
|||||||
os_media_controls:
|
os_media_controls:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/edde746/os-media-controls
|
url: https://github.com/edde746/os-media-controls
|
||||||
ref: 5ddd27c
|
ref: d5c1885
|
||||||
rate_limiter: ^1.0.0
|
rate_limiter: ^1.0.0
|
||||||
wakelock_plus:
|
wakelock_plus:
|
||||||
git:
|
git:
|
||||||
|
|||||||
Reference in New Issue
Block a user