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:
edde746
2026-08-20 02:25:16 +02:00
parent e71580def9
commit 1e83e4e6c2
8 changed files with 234 additions and 141 deletions
@@ -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
);
}
}
+1 -1
View File
@@ -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();
}
}
+24 -5
View File
@@ -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) {