chore(player): extract OS media-controls adapter into an owned controller
The Android TV background suspension latch, availability policy, and resume/rewind restore logic lived as an extension on the 149-field player State, with the suspension flag read and reset from three other parts. Move them into MediaControlsScreenController, a plain State-owned helper following the established player pattern: the manager, player, and current item are injected as late-bound getters because they are re-created per playback attempt. The controller now solely owns the suspension latch; teardown clears it via resetSuspension() instead of writing the State field directly.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../media/media_item.dart';
|
||||
import '../../media/media_item_types.dart';
|
||||
import '../../media/media_server_client.dart';
|
||||
import '../../mpv/mpv.dart';
|
||||
import '../../services/media_controls_manager.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/player_utils.dart';
|
||||
import 'wakelock_controller.dart';
|
||||
|
||||
/// Screen-side adapter over [MediaControlsManager]: owns the Android TV
|
||||
/// background suspension latch and the availability/restore-on-resume
|
||||
/// policies for the OS media session.
|
||||
///
|
||||
/// Plain State-owned object following the established player helper pattern
|
||||
/// (see [WakelockController]). Every dependency that can change across a
|
||||
/// playback attempt — the manager, the player, the current item — is injected
|
||||
/// as a late-bound getter because both are re-created on in-place reloads and
|
||||
/// nulled during teardown; caching them here would touch freed objects.
|
||||
class MediaControlsScreenController {
|
||||
MediaControlsScreenController({
|
||||
required this._manager,
|
||||
required this._player,
|
||||
required this._isMounted,
|
||||
required this.isLive,
|
||||
required this._shouldSkipForPip,
|
||||
required this._isPlayerInitialized,
|
||||
required this._metadata,
|
||||
required this._client,
|
||||
required this._isPlaylistActive,
|
||||
required this._canControlPlayback,
|
||||
required this._canNavigateMediaItems,
|
||||
required this._rewindOnResumeSeconds,
|
||||
required this._seek,
|
||||
required this._play,
|
||||
required this._wasPlayingBeforeInactive,
|
||||
required this._clearWasPlayingBeforeInactive,
|
||||
required this._wakelock,
|
||||
required this._recordLifecycle,
|
||||
});
|
||||
|
||||
final MediaControlsManager? Function() _manager;
|
||||
final Player? Function() _player;
|
||||
final bool Function() _isMounted;
|
||||
final bool isLive;
|
||||
final bool Function() _shouldSkipForPip;
|
||||
final bool Function() _isPlayerInitialized;
|
||||
final MediaItem Function() _metadata;
|
||||
final MediaServerClient? Function() _client;
|
||||
final bool Function() _isPlaylistActive;
|
||||
final bool Function() _canControlPlayback;
|
||||
final bool Function() _canNavigateMediaItems;
|
||||
final int Function() _rewindOnResumeSeconds;
|
||||
final Future<void> Function(Duration position) _seek;
|
||||
final Future<void> Function(Player player) _play;
|
||||
final bool Function() _wasPlayingBeforeInactive;
|
||||
final void Function() _clearWasPlayingBeforeInactive;
|
||||
final WakelockController _wakelock;
|
||||
final void Function(String state, {String? action}) _recordLifecycle;
|
||||
|
||||
bool _suspendedForTvBackground = false;
|
||||
|
||||
/// Whether OS media-control events are currently ignored because the app is
|
||||
/// backgrounded on Android TV.
|
||||
bool get suspendedForTvBackground => _suspendedForTvBackground;
|
||||
|
||||
bool get shouldSuspendForTvBackground => Platform.isAndroid && PlatformDetector.isTV() && !_shouldSkipForPip();
|
||||
|
||||
Future<void> suspendForTvBackground(String reason) async {
|
||||
if (!shouldSuspendForTvBackground) return;
|
||||
|
||||
_manager()?.suspendUpdates();
|
||||
if (!_suspendedForTvBackground) {
|
||||
_suspendedForTvBackground = true;
|
||||
_recordLifecycle('media_controls', action: 'suspended:$reason');
|
||||
}
|
||||
|
||||
await _manager()?.clear();
|
||||
}
|
||||
|
||||
void resumeAfterTvBackground(String reason) {
|
||||
if (!_suspendedForTvBackground) return;
|
||||
|
||||
_suspendedForTvBackground = false;
|
||||
_manager()?.resumeUpdates();
|
||||
_recordLifecycle('media_controls', action: 'resumed:$reason');
|
||||
}
|
||||
|
||||
/// Clears the suspension latch without touching the manager — used when the
|
||||
/// manager itself is being torn down with the playback attempt.
|
||||
void resetSuspension() {
|
||||
_suspendedForTvBackground = false;
|
||||
}
|
||||
|
||||
Future<void> syncAvailability() async {
|
||||
if (_suspendedForTvBackground) return;
|
||||
|
||||
final manager = _manager();
|
||||
final currentPlayer = _player();
|
||||
if (!_isMounted() || manager == null || currentPlayer == null) return;
|
||||
|
||||
final hasNavigableItems = _metadata().isEpisode || _isPlaylistActive();
|
||||
final contentCanSeek = !isLive && currentPlayer.state.seekable;
|
||||
final canControlPlayback = _canControlPlayback();
|
||||
final canNavigateMediaItems = _canNavigateMediaItems();
|
||||
|
||||
await manager.setControlsEnabled(
|
||||
canPlayPause: canControlPlayback,
|
||||
canGoNext: hasNavigableItems && canNavigateMediaItems,
|
||||
canGoPrevious: hasNavigableItems && canNavigateMediaItems,
|
||||
canSeek: contentCanSeek && canControlPlayback,
|
||||
canStop: true,
|
||||
// In-track skips work on live TV too through the capture buffer.
|
||||
canSkip: canControlPlayback,
|
||||
// Video claims the lock-screen / remote-card side slots for ±skip
|
||||
// (#1994); the step mirrors the in-player small skip. A mid-playback
|
||||
// seekTimeSmall change applies on the next availability sync.
|
||||
preferSkipOverTrackButtons: true,
|
||||
skipInterval: Duration(seconds: SettingsService.instance.read(SettingsService.seekTimeSmall)),
|
||||
// Rate changes don't apply to a live stream.
|
||||
canSetSpeed: !isLive && canControlPlayback,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> seekBackForRewind(Player p) async {
|
||||
final rewindOnResume = _rewindOnResumeSeconds();
|
||||
if (rewindOnResume <= 0) return;
|
||||
final target = p.state.position - Duration(seconds: rewindOnResume);
|
||||
await _seek(clampSeekPosition(p, target));
|
||||
}
|
||||
|
||||
Future<void> restoreAfterResume() async {
|
||||
if (!_isPlayerInitialized() || !_isMounted()) return;
|
||||
|
||||
unawaited(_wakelock.setEnabled(_player()?.state.isActive ?? false));
|
||||
|
||||
final manager = _manager();
|
||||
final currentPlayer = _player();
|
||||
if (manager != null && currentPlayer != null) {
|
||||
final metadata = _metadata();
|
||||
final durationMs = metadata.durationMs;
|
||||
await manager.updateMetadata(
|
||||
metadata: metadata,
|
||||
client: _client(),
|
||||
duration: durationMs != null ? Duration(milliseconds: durationMs) : null,
|
||||
);
|
||||
await syncAvailability();
|
||||
}
|
||||
|
||||
if (!_isMounted() || currentPlayer != _player() || currentPlayer == null) return;
|
||||
|
||||
final wasPlayingBeforeInactive = _wasPlayingBeforeInactive();
|
||||
if (wasPlayingBeforeInactive) {
|
||||
try {
|
||||
await seekBackForRewind(currentPlayer);
|
||||
await _play(currentPlayer);
|
||||
appLogger.d('Video resumed after returning from inactive state');
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to resume playback after returning from inactive state', error: e);
|
||||
} finally {
|
||||
_clearWasPlayingBeforeInactive();
|
||||
}
|
||||
}
|
||||
|
||||
pushPlaybackState();
|
||||
appLogger.d('Media controls restored on app resume');
|
||||
}
|
||||
|
||||
/// Pushes the current playback state to the OS media session.
|
||||
void pushPlaybackState() {
|
||||
if (_suspendedForTvBackground) return;
|
||||
final currentPlayer = _player();
|
||||
if (currentPlayer == null) return;
|
||||
|
||||
_manager()?.updatePlaybackState(
|
||||
isPlaying: currentPlayer.state.isActive,
|
||||
position: currentPlayer.state.position,
|
||||
speed: currentPlayer.state.rate,
|
||||
force: true, // Force update since this is an explicit state change
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -246,7 +246,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
if (_lastMediaControlAuthority != authority) {
|
||||
_lastMediaControlAuthority = authority;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(_syncMediaControlsAvailability());
|
||||
if (mounted) unawaited(_mediaControls.syncAvailability());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
if (!mounted || currentPlayer != player) return;
|
||||
|
||||
_notifyWatchTogetherSeek(target);
|
||||
_updateMediaControlsPlaybackState();
|
||||
_mediaControls.pushPlaybackState();
|
||||
}
|
||||
|
||||
/// Replace this screen with a fresh player route — the fallback for flows
|
||||
|
||||
@@ -30,7 +30,7 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
|
||||
'pipTransitionInFlight': _androidAutoPipTransitionInFlight,
|
||||
'hiddenForBackground': _hiddenForBackground,
|
||||
'playerSuspendedForTvBackground': _playerSuspendedForTvBackground,
|
||||
'mediaControlsSuspendedForTvBackground': _mediaControlsSuspendedForTvBackground,
|
||||
'mediaControlsSuspendedForTvBackground': _mediaControls.suspendedForTvBackground,
|
||||
'backend': _playerBackendLabel,
|
||||
};
|
||||
if (action != null) {
|
||||
@@ -50,7 +50,7 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
|
||||
' pipTransitionInFlight=$_androidAutoPipTransitionInFlight'
|
||||
' hiddenForBackground=$_hiddenForBackground'
|
||||
' playerSuspendedForTvBackground=$_playerSuspendedForTvBackground'
|
||||
' mediaControlsSuspendedForTvBackground=$_mediaControlsSuspendedForTvBackground'
|
||||
' mediaControlsSuspendedForTvBackground=$_mediaControls.suspendedForTvBackground'
|
||||
' backend=$_playerBackendLabel',
|
||||
);
|
||||
}
|
||||
@@ -106,7 +106,7 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
|
||||
}
|
||||
await stoppedReport;
|
||||
if (!mounted || currentPlayer != player) return;
|
||||
await _suspendMediaControlsForTvBackground('hidden_live_stopped');
|
||||
await _mediaControls.suspendForTvBackground('hidden_live_stopped');
|
||||
_recordLifecycleState('hidden', action: 'live_stopped_exit_on_resume');
|
||||
return;
|
||||
}
|
||||
@@ -159,7 +159,7 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
|
||||
_suspendLiveTimelineForBackground();
|
||||
|
||||
if (isTv) {
|
||||
await _suspendMediaControlsForTvBackground('hidden');
|
||||
await _mediaControls.suspendForTvBackground('hidden');
|
||||
if (_armTvBackgroundPlayerSuspendTimer()) {
|
||||
_recordLifecycleState('hidden', action: 'tv_background_pause_suspend_armed');
|
||||
} else {
|
||||
@@ -228,8 +228,8 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
|
||||
|
||||
// Restore media controls and wakelock when app is resumed.
|
||||
if (_isPlayerInitialized && mounted) {
|
||||
_resumeMediaControlsAfterTvBackground('app_resumed');
|
||||
await _restoreMediaControlsAfterResume();
|
||||
_mediaControls.resumeAfterTvBackground('app_resumed');
|
||||
await _mediaControls.restoreAfterResume();
|
||||
}
|
||||
|
||||
_resumeLiveTimelineAfterBackgroundIfNeeded();
|
||||
@@ -350,7 +350,7 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
|
||||
/// place through the regular reload flow — a fresh playback decision, since
|
||||
/// the old session is closed and its stream URL may have expired
|
||||
/// server-side — and comes back paused; the caller's
|
||||
/// [_restoreMediaControlsAfterResume] then resumes it (with
|
||||
/// [MediaControlsScreenController.restoreAfterResume] then resumes it (with
|
||||
/// rewind-on-resume) exactly like a plain background pause. Live sessions
|
||||
/// never enter this flow because their tuned session and capture-buffer
|
||||
/// position must remain intact across backgrounding.
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
part of '../../video_player_screen.dart';
|
||||
|
||||
extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState {
|
||||
bool get _shouldSuspendMediaControlsForTvBackground =>
|
||||
Platform.isAndroid && PlatformDetector.isTV() && !_shouldSkipForPip;
|
||||
|
||||
Future<void> _suspendMediaControlsForTvBackground(String reason) async {
|
||||
if (!_shouldSuspendMediaControlsForTvBackground) return;
|
||||
|
||||
_mediaControlsManager?.suspendUpdates();
|
||||
if (!_mediaControlsSuspendedForTvBackground) {
|
||||
_mediaControlsSuspendedForTvBackground = true;
|
||||
_recordLifecycleState('media_controls', action: 'suspended:$reason');
|
||||
}
|
||||
|
||||
await _mediaControlsManager?.clear();
|
||||
}
|
||||
|
||||
void _resumeMediaControlsAfterTvBackground(String reason) {
|
||||
if (!_mediaControlsSuspendedForTvBackground) return;
|
||||
|
||||
_mediaControlsSuspendedForTvBackground = false;
|
||||
_mediaControlsManager?.resumeUpdates();
|
||||
_recordLifecycleState('media_controls', action: 'resumed:$reason');
|
||||
}
|
||||
|
||||
Future<void> _syncMediaControlsAvailability() async {
|
||||
if (_mediaControlsSuspendedForTvBackground) return;
|
||||
|
||||
final manager = _mediaControlsManager;
|
||||
final currentPlayer = player;
|
||||
if (!mounted || manager == null || currentPlayer == null) return;
|
||||
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
final hasNavigableItems = _currentMetadata.isEpisode || playbackState.isPlaylistActive;
|
||||
final contentCanSeek = !widget.isLive && currentPlayer.state.seekable;
|
||||
final canControlPlayback = _canControlPlayback();
|
||||
final canNavigateMediaItems = _canNavigateMediaItems();
|
||||
|
||||
await manager.setControlsEnabled(
|
||||
canPlayPause: canControlPlayback,
|
||||
canGoNext: hasNavigableItems && canNavigateMediaItems,
|
||||
canGoPrevious: hasNavigableItems && canNavigateMediaItems,
|
||||
canSeek: contentCanSeek && canControlPlayback,
|
||||
canStop: true,
|
||||
// In-track skips work on live TV too through the capture buffer.
|
||||
canSkip: canControlPlayback,
|
||||
// Video claims the lock-screen / remote-card side slots for ±skip
|
||||
// (#1994); the step mirrors the in-player small skip. A mid-playback
|
||||
// seekTimeSmall change applies on the next availability sync.
|
||||
preferSkipOverTrackButtons: true,
|
||||
skipInterval: Duration(seconds: SettingsService.instance.read(SettingsService.seekTimeSmall)),
|
||||
// Rate changes don't apply to a live stream.
|
||||
canSetSpeed: !widget.isLive && canControlPlayback,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _seekBackForRewind(Player p) async {
|
||||
if (_rewindOnResume <= 0) return;
|
||||
final target = p.state.position - Duration(seconds: _rewindOnResume);
|
||||
await _seekPlayback(clampSeekPosition(p, target));
|
||||
}
|
||||
|
||||
Future<void> _restoreMediaControlsAfterResume() async {
|
||||
if (!_isPlayerInitialized || !mounted) return;
|
||||
|
||||
unawaited(_wakelockController.setEnabled(player?.state.isActive ?? false));
|
||||
|
||||
final manager = _mediaControlsManager;
|
||||
final currentPlayer = player;
|
||||
if (manager != null && currentPlayer != null) {
|
||||
final client = _isOfflinePlayback ? null : _getMediaServerClient(context);
|
||||
await manager.updateMetadata(
|
||||
metadata: _currentMetadata,
|
||||
client: client,
|
||||
duration: _currentMetadata.durationMs != null ? Duration(milliseconds: _currentMetadata.durationMs!) : null,
|
||||
);
|
||||
await _syncMediaControlsAvailability();
|
||||
}
|
||||
|
||||
if (!mounted || currentPlayer != player || currentPlayer == null) return;
|
||||
|
||||
final wasPlayingBeforeInactive = _wasPlayingBeforeInactive;
|
||||
if (wasPlayingBeforeInactive) {
|
||||
try {
|
||||
await _seekBackForRewind(currentPlayer);
|
||||
await _playWithPlaybackIntent(currentPlayer);
|
||||
appLogger.d('Video resumed after returning from inactive state');
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to resume playback after returning from inactive state', error: e);
|
||||
} finally {
|
||||
_wasPlayingBeforeInactive = false;
|
||||
}
|
||||
}
|
||||
|
||||
_updateMediaControlsPlaybackState();
|
||||
appLogger.d('Media controls restored on app resume');
|
||||
}
|
||||
|
||||
/// Wrapper method to update media controls playback state
|
||||
void _updateMediaControlsPlaybackState() {
|
||||
if (_mediaControlsSuspendedForTvBackground) return;
|
||||
if (player == null) return;
|
||||
|
||||
_mediaControlsManager?.updatePlaybackState(
|
||||
isPlaying: player!.state.isActive,
|
||||
position: player!.state.position,
|
||||
speed: player!.state.rate,
|
||||
force: true, // Force update since this is an explicit state change
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
|
||||
? _sendStoppedProgressOnce(positionOverride: duration)
|
||||
: _sendStoppedProgressOnce(),
|
||||
);
|
||||
_updateMediaControlsPlaybackState();
|
||||
_mediaControls.pushPlaybackState();
|
||||
unawaited(DiscordRPCService.instance.pausePlayback());
|
||||
// The item finished, so real-time trackers get a terminal report now rather
|
||||
// than whenever the screen happens to tear down: a completion prompt or
|
||||
|
||||
@@ -264,7 +264,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
_audioFocusFuture = null;
|
||||
_playbackDataFuture = null;
|
||||
_playbackSession = null;
|
||||
_mediaControlsSuspendedForTvBackground = false;
|
||||
_mediaControls.resetSuspension();
|
||||
|
||||
if (progressTracker != null) {
|
||||
try {
|
||||
@@ -439,11 +439,11 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
onPlay: () {
|
||||
final currentPlayer = player;
|
||||
if (currentPlayer == null) return;
|
||||
unawaited(_seekBackForRewind(currentPlayer));
|
||||
unawaited(_mediaControls.seekBackForRewind(currentPlayer));
|
||||
unawaited(_playWithPlaybackIntent(currentPlayer));
|
||||
_wasPlayingBeforeInactive = false;
|
||||
_announceTransportCommand(willPlay: true);
|
||||
_updateMediaControlsPlaybackState();
|
||||
_mediaControls.pushPlaybackState();
|
||||
},
|
||||
onPause: () {
|
||||
final currentPlayer = player;
|
||||
@@ -454,7 +454,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
}
|
||||
unawaited(_pauseWithPlaybackIntent(currentPlayer));
|
||||
_announceTransportCommand(willPlay: false);
|
||||
_updateMediaControlsPlaybackState();
|
||||
_mediaControls.pushPlaybackState();
|
||||
},
|
||||
onTogglePlayPause: () {
|
||||
final currentPlayer = player;
|
||||
@@ -463,12 +463,12 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
unawaited(_pauseWithPlaybackIntent(currentPlayer));
|
||||
_announceTransportCommand(willPlay: false);
|
||||
} else {
|
||||
unawaited(_seekBackForRewind(currentPlayer));
|
||||
unawaited(_mediaControls.seekBackForRewind(currentPlayer));
|
||||
unawaited(_playWithPlaybackIntent(currentPlayer));
|
||||
_wasPlayingBeforeInactive = false;
|
||||
_announceTransportCommand(willPlay: true);
|
||||
}
|
||||
_updateMediaControlsPlaybackState();
|
||||
_mediaControls.pushPlaybackState();
|
||||
},
|
||||
onSeek: (position) {
|
||||
final currentPlayer = player;
|
||||
@@ -491,7 +491,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
|
||||
// Set up media control event handling
|
||||
_mediaControlSubscription = mediaControlsManager.controlEvents.listen((event) {
|
||||
if (_mediaControlsSuspendedForTvBackground) {
|
||||
if (_mediaControls.suspendedForTvBackground) {
|
||||
appLogger.d('Media control: ${event.runtimeType} ignored while Android TV background-suspended');
|
||||
return;
|
||||
}
|
||||
@@ -523,12 +523,12 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
await _syncMediaControlsAvailability();
|
||||
await _mediaControls.syncAvailability();
|
||||
if (!mounted || player != currentPlayer || _mediaControlsManager != mediaControlsManager) return;
|
||||
|
||||
// Listen to playing state and update media controls
|
||||
_mediaControlsPlayingSubscription = currentPlayer.streams.playing.listen((isPlaying) {
|
||||
_updateMediaControlsPlaybackState();
|
||||
_mediaControls.pushPlaybackState();
|
||||
});
|
||||
|
||||
// Listen to position updates for media controls and Discord
|
||||
@@ -552,7 +552,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
});
|
||||
|
||||
_mediaControlsSeekableSubscription = currentPlayer.streams.seekable.listen((_) {
|
||||
unawaited(_syncMediaControlsAvailability());
|
||||
unawaited(_mediaControls.syncAvailability());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -579,7 +579,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaying && _mediaControlsSuspendedForTvBackground) {
|
||||
if (isPlaying && _mediaControls.suspendedForTvBackground) {
|
||||
appLogger.w('Playback started while Android TV background media controls are suspended; pausing');
|
||||
Sentry.addBreadcrumb(
|
||||
Breadcrumb(message: 'Blocked TV background playback start', category: 'player.media_controls'),
|
||||
@@ -605,7 +605,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
_progressTracker?.sendProgress(isPlaying ? 'playing' : 'paused');
|
||||
|
||||
// Update OS media controls playback state
|
||||
_updateMediaControlsPlaybackState();
|
||||
_mediaControls.pushPlaybackState();
|
||||
|
||||
// Update Discord Rich Presence + real-time trackers
|
||||
if (isPlaying) {
|
||||
@@ -664,7 +664,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to pause after Apple audio session $reason', error: e);
|
||||
} finally {
|
||||
_updateMediaControlsPlaybackState();
|
||||
_mediaControls.pushPlaybackState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -692,7 +692,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to resume after Apple audio session $reason', error: e);
|
||||
} finally {
|
||||
_updateMediaControlsPlaybackState();
|
||||
_mediaControls.pushPlaybackState();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ import '../utils/video_player_navigation.dart';
|
||||
import '../utils/android_exit_diagnostics.dart';
|
||||
import 'video_player/completion_latch.dart';
|
||||
import 'video_player/frame_rate_matcher.dart';
|
||||
import 'video_player/media_controls_screen_controller.dart';
|
||||
import 'video_player/live_stream_retry.dart';
|
||||
import 'video_player/live_timeline_report.dart';
|
||||
import 'video_player/wakelock_controller.dart';
|
||||
@@ -118,7 +119,6 @@ part 'video_player/parts/episode_queue.dart';
|
||||
part 'video_player/parts/errors.dart';
|
||||
part 'video_player/parts/lifecycle.dart';
|
||||
part 'video_player/parts/live_tv.dart';
|
||||
part 'video_player/parts/media_controls.dart';
|
||||
part 'video_player/parts/pip.dart';
|
||||
part 'video_player/parts/shader.dart';
|
||||
part 'video_player/parts/playback_open.dart';
|
||||
@@ -691,7 +691,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// App lifecycle state tracking
|
||||
bool _wasPlayingBeforeInactive = false;
|
||||
bool _hiddenForBackground = false;
|
||||
bool _mediaControlsSuspendedForTvBackground = false;
|
||||
bool _resumeAfterAppleAudioSessionPause = false;
|
||||
DateTime? _lastPlaybackPauseAt;
|
||||
bool _autoPipEnabled = false;
|
||||
@@ -736,6 +735,26 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
(Platform.isAndroid && _androidAutoPipTransitionInFlight);
|
||||
|
||||
MediaControlsManager? _mediaControlsManager;
|
||||
late final MediaControlsScreenController _mediaControls = MediaControlsScreenController(
|
||||
manager: () => _mediaControlsManager,
|
||||
player: () => player,
|
||||
isMounted: () => mounted,
|
||||
isLive: widget.isLive,
|
||||
shouldSkipForPip: () => _shouldSkipForPip,
|
||||
isPlayerInitialized: () => _isPlayerInitialized,
|
||||
metadata: () => _currentMetadata,
|
||||
client: () => _isOfflinePlayback ? null : _getMediaServerClient(context),
|
||||
isPlaylistActive: () => context.read<PlaybackStateProvider>().isPlaylistActive,
|
||||
canControlPlayback: () => _canControlPlayback(),
|
||||
canNavigateMediaItems: () => _canNavigateMediaItems(),
|
||||
rewindOnResumeSeconds: () => _rewindOnResume,
|
||||
seek: (position) => _seekPlayback(position),
|
||||
play: _playWithPlaybackIntent,
|
||||
wasPlayingBeforeInactive: () => _wasPlayingBeforeInactive,
|
||||
clearWasPlayingBeforeInactive: () => _wasPlayingBeforeInactive = false,
|
||||
wakelock: _wakelockController,
|
||||
recordLifecycle: (state, {action}) => _recordLifecycleState(state, action: action),
|
||||
);
|
||||
({bool canControlPlayback, bool canNavigateMediaItems})? _lastMediaControlAuthority;
|
||||
PlaybackProgressTracker? _progressTracker;
|
||||
VideoFilterManager? _videoFilterManager;
|
||||
@@ -1194,8 +1213,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
break;
|
||||
}
|
||||
// We don't support background playback
|
||||
if (_shouldSuspendMediaControlsForTvBackground) {
|
||||
unawaited(_suspendMediaControlsForTvBackground('paused'));
|
||||
if (_mediaControls.shouldSuspendForTvBackground) {
|
||||
unawaited(_mediaControls.suspendForTvBackground('paused'));
|
||||
} else {
|
||||
unawaited(_mediaControlsManager?.clear());
|
||||
}
|
||||
@@ -2199,7 +2218,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
try {
|
||||
if (resumes) {
|
||||
await _seekBackForRewind(currentPlayer);
|
||||
await _mediaControls.seekBackForRewind(currentPlayer);
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
}
|
||||
await switch (command) {
|
||||
|
||||
Reference in New Issue
Block a user