fix: disable android tv pip lifecycle regressions

This commit is contained in:
edde746
2026-04-09 06:39:17 +02:00
parent 8b40e0993f
commit 4322ffc6e5
3 changed files with 154 additions and 43 deletions
@@ -49,6 +49,10 @@ class MainActivity : FlutterActivity() {
private var autoPipWidth: Int = 16
private var autoPipHeight: Int = 9
private fun isAndroidTvDevice(): Boolean {
return packageManager.hasSystemFeature("android.software.leanback")
}
override fun onCreate(savedInstanceState: Bundle?) {
// Apply persisted theme color to the window background before anything
// else renders. This prevents a white flash between the native splash
@@ -280,7 +284,7 @@ class MainActivity : FlutterActivity() {
MethodChannel( flutterEngine.dartExecutor.binaryMessenger, PIP_CHANNEL ).setMethodCallHandler { call, result ->
when (call.method) {
"isSupported" -> {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isAndroidTvDevice())
}
"enter" -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
@@ -288,6 +292,11 @@ class MainActivity : FlutterActivity() {
return@setMethodCallHandler
}
if (isAndroidTvDevice()) {
result.success(mapOf("success" to false, "errorCode" to "not_supported"))
return@setMethodCallHandler
}
if (!isPipPermissionGranted()) {
result.success(mapOf("success" to false, "errorCode" to "permission_disabled"))
return@setMethodCallHandler
@@ -310,6 +319,12 @@ class MainActivity : FlutterActivity() {
}
}
"setAutoPipReady" -> {
if (isAndroidTvDevice()) {
autoPipReady = false
result.success(true)
return@setMethodCallHandler
}
autoPipReady = call.argument<Boolean>("ready") ?: false
autoPipWidth = call.argument<Int>("width") ?: 16
autoPipHeight = call.argument<Int>("height") ?: 9
@@ -342,7 +357,8 @@ class MainActivity : FlutterActivity() {
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// Auto PiP for API 26-30 (API 31+ uses setAutoEnterEnabled)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
if (!isAndroidTvDevice() &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
Build.VERSION.SDK_INT < Build.VERSION_CODES.S &&
autoPipReady && isPipPermissionGranted()) {
try {
+130 -40
View File
@@ -223,14 +223,20 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
bool _wasPlayingBeforeInactive = false;
bool _hiddenForBackground = false;
bool _autoPipEnabled = false;
bool _androidAutoPipTransitionInFlight = false;
bool _resumeLiveTimelineOnResume = false;
int _rewindOnResume = 0;
Future<void> _lifecycleTransition = Future<void>.value();
String _playerBackendLabel = 'unknown';
/// Whether to skip lifecycle actions because PiP is active or about to start.
/// iOS auto-PiP is system-initiated during the background transition, so
/// isPipActive may not be true yet — we also check the auto-PiP setting.
/// Apple auto-PiP is system-initiated during the background transition, and
/// Android auto-PiP on API 26-30 has a brief native transition window before
/// onPipChanged fires.
bool get _shouldSkipForPip =>
PipService().isPipActive.value || ((Platform.isIOS || Platform.isMacOS) && _autoPipEnabled);
PipService().isPipActive.value ||
((Platform.isIOS || Platform.isMacOS) && _autoPipEnabled) ||
(Platform.isAndroid && _androidAutoPipTransitionInFlight);
// Services
MediaControlsManager? _mediaControlsManager;
@@ -360,25 +366,32 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
switch (state) {
case AppLifecycleState.inactive:
_recordLifecycleState('inactive');
// App is inactive (notification shade, split-screen, etc.)
// Don't pause - user may still be watching
break;
case AppLifecycleState.hidden:
_recordLifecycleState('hidden');
_enqueueLifecycleTransition('hidden', _handleAppHidden);
break;
case AppLifecycleState.paused:
if (_shouldSkipForPip) break;
if (_shouldSkipForPip) {
_recordLifecycleState('paused', action: 'skipped_for_pip');
break;
}
// Clear media controls when app truly goes to background
// (we don't support background playback)
_mediaControlsManager?.clear();
// Disable wakelock when app goes to background
_setWakelock(false);
appLogger.d('Media controls cleared and wakelock disabled due to app being paused/backgrounded');
_recordLifecycleState('paused', action: 'backgrounded');
break;
case AppLifecycleState.resumed:
_recordLifecycleState('resumed');
_enqueueLifecycleTransition('resumed', _handleAppResumed);
break;
case AppLifecycleState.detached:
_recordLifecycleState('detached');
// No action needed for this state
break;
}
@@ -399,35 +412,107 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
});
}
void _recordLifecycleState(String state, {String? action}) {
final isTv = PlatformDetector.isTV();
final pipActive = PipService().isPipActive.value;
final breadcrumbData = <String, dynamic>{
'state': state,
'isTv': isTv,
'autoPipEnabled': _autoPipEnabled,
'pipActive': pipActive,
'pipTransitionInFlight': _androidAutoPipTransitionInFlight,
'hiddenForBackground': _hiddenForBackground,
'backend': _playerBackendLabel,
};
if (action != null) {
breadcrumbData['action'] = action;
}
Sentry.addBreadcrumb(
Breadcrumb(message: 'Player lifecycle $state', category: 'player.lifecycle', data: breadcrumbData),
);
appLogger.d(
'Player lifecycle: state=$state'
'${action != null ? ' action=$action' : ''}'
' isTv=$isTv'
' autoPipEnabled=$_autoPipEnabled'
' pipActive=$pipActive'
' pipTransitionInFlight=$_androidAutoPipTransitionInFlight'
' hiddenForBackground=$_hiddenForBackground'
' backend=$_playerBackendLabel',
);
}
void _setAndroidAutoPipTransitionInFlight(bool value, {required String reason}) {
if (!Platform.isAndroid || _androidAutoPipTransitionInFlight == value) return;
_androidAutoPipTransitionInFlight = value;
_recordLifecycleState('pip_transition', action: '${value ? 'started' : 'cleared'}:$reason');
}
void _suspendLiveTimelineForBackground() {
_resumeLiveTimelineOnResume = _liveTimelineTimer != null;
_stopLiveTimelineUpdates();
}
void _resumeLiveTimelineAfterBackgroundIfNeeded() {
final shouldResume = _resumeLiveTimelineOnResume;
_resumeLiveTimelineOnResume = false;
if (shouldResume && _liveSessionIdentifier != null) {
_startLiveTimelineUpdates();
}
}
Future<void> _handleAppHidden() async {
if (_shouldSkipForPip) return;
if (_shouldSkipForPip) {
_recordLifecycleState('hidden', action: 'skipped_for_pip');
return;
}
final currentPlayer = player;
if (currentPlayer == null || !_isPlayerInitialized) return;
if (currentPlayer == null || !_isPlayerInitialized) {
_recordLifecycleState('hidden', action: 'skipped_no_player');
return;
}
final isTv = PlatformDetector.isTV();
final shouldPauseForBackground = PlatformDetector.isHandheld(context) || isTv;
// Pause first so Android MPV does not keep decoding against a transient
// background surface while the app is locking or hiding.
if (PlatformDetector.isMobile(context)) {
if (shouldPauseForBackground) {
_wasPlayingBeforeInactive = currentPlayer.state.isActive;
if (_wasPlayingBeforeInactive) {
try {
await currentPlayer.pause();
appLogger.d('Video paused due to app being hidden (mobile)');
appLogger.d('Video paused due to app being hidden (${isTv ? 'tv' : 'handheld'})');
} catch (e) {
appLogger.w('Failed to pause video before hiding render layer', error: e);
appLogger.w('Failed to pause video before background transition', error: e);
}
}
}
if (!mounted || currentPlayer != player) return;
_suspendLiveTimelineForBackground();
if (isTv) {
_recordLifecycleState('hidden', action: 'tv_background_pause_only');
return;
}
_hiddenForBackground = true;
_liveTimelineTimer?.cancel();
await currentPlayer.setVisible(false);
appLogger.d('Render layer hidden due to app being hidden');
_recordLifecycleState('hidden', action: 'render_hidden');
}
Future<void> _handleAppResumed() async {
_recordLifecycleState('resumed', action: 'begin');
if (Platform.isAndroid && _androidAutoPipTransitionInFlight && !PipService().isPipActive.value) {
_setAndroidAutoPipTransitionInFlight(false, reason: 'resume_without_pip');
}
final currentPlayer = player;
// Restore render layer if it was hidden for background, then force a
@@ -439,16 +524,16 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (!mounted || currentPlayer != player) return;
_hiddenForBackground = false;
if (_liveSessionIdentifier != null) {
_startLiveTimelineUpdates();
}
appLogger.d('Render layer restored after app resumed');
_recordLifecycleState('resumed', action: 'render_restored');
}
// Restore media controls and wakelock when app is resumed.
if (_isPlayerInitialized && mounted) {
await _restoreMediaControlsAfterResume();
}
_resumeLiveTimelineAfterBackgroundIfNeeded();
_recordLifecycleState('resumed', action: 'complete');
}
Future<void> _initializePlayer() async {
@@ -471,6 +556,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Create player (on Android, uses ExoPlayer by default, MPV as fallback)
player = Player(useExoPlayer: useExoPlayer);
_playerBackendLabel = player!.playerType;
await player!.configureSubtitleFonts();
await player!.setProperty('sub-ass', 'yes'); // Enable libass
@@ -805,10 +891,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final sigPeakStr = await player!.getProperty('video-params/sig-peak');
final sigPeak = double.tryParse(sigPeakStr ?? '');
final delay = await _displayModeService!.applyDisplayMatching(
fps: fps,
sigPeak: sigPeak,
);
final delay = await _displayModeService!.applyDisplayMatching(fps: fps, sigPeak: sigPeak);
if (delay > Duration.zero) {
await Future.delayed(delay);
@@ -1132,8 +1215,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final nowEpoch = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final effectiveStart = max(_captureBuffer!.seekableStartEpoch, _programBeginsAt!);
final elapsed = nowEpoch - effectiveStart;
appLogger.d('Time-shift: buffer=${_captureBuffer!.seekableDurationSeconds}s, '
'beginsAt=$_programBeginsAt, elapsed=${elapsed}s (need >60 for dialog)');
appLogger.d(
'Time-shift: buffer=${_captureBuffer!.seekableDurationSeconds}s, '
'beginsAt=$_programBeginsAt, elapsed=${elapsed}s (need >60 for dialog)',
);
if (elapsed > 60) {
final watchFromStart = await _showWatchFromStartDialog(effectiveStart, nowEpoch);
if (!mounted) return;
@@ -1312,6 +1397,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Auto-PiP: set up callback for API 26-30 path and initial state
if (_autoPipEnabled) {
PipService.onAutoPipEntering = () {
_setAndroidAutoPipTransitionInFlight(true, reason: 'native_auto_pip_entering');
_videoFilterManager?.enterPipMode();
};
if (player!.state.playing) {
@@ -1492,9 +1578,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
/// Handle PiP state changes to restore video scaling when exiting PiP
void _onPipStateChanged() {
final isInPip = _videoPIPManager?.isPipActive.value ?? PipService().isPipActive.value;
_setAndroidAutoPipTransitionInFlight(false, reason: 'pip_state_changed');
_recordLifecycleState('pip_state_changed', action: isInPip ? 'entered' : 'exited');
if (_videoPIPManager == null || _videoFilterManager == null) return;
final isInPip = _videoPIPManager!.isPipActive.value;
// Only handle exit - entry is handled by onBeforeEnterPip callback
if (!isInPip) {
final restoreAmbient = _videoFilterManager!.hadAmbientLightingBeforePip;
@@ -2083,10 +2172,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_videoPIPManager?.updateAutoPipState(isPlaying: false);
}
if (_nextEpisode != null &&
!_showPlayNextDialog &&
!_showStillWatchingPrompt &&
!_completionTriggered) {
if (_nextEpisode != null && !_showPlayNextDialog && !_showStillWatchingPrompt && !_completionTriggered) {
_completionTriggered = true;
// Capture keyboard mode before async gap
@@ -2146,6 +2232,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
/// Handle notification when native player switched from ExoPlayer to MPV
Future<void> _onBackendSwitched() async {
_playerBackendLabel = 'mpv';
_recordLifecycleState('backend_switched', action: 'mpv_fallback');
if (mounted) {
showAppSnackBar(context, t.messages.switchingToCompatiblePlayer);
}
@@ -2334,7 +2423,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final client = widget.liveClient;
final channels = widget.liveChannels;
final channelIndex = _liveChannelIndex;
if (client == null || channels == null || channelIndex < 0 || channelIndex >= channels.length || widget.liveDvrKey == null) {
if (client == null ||
channels == null ||
channelIndex < 0 ||
channelIndex >= channels.length ||
widget.liveDvrKey == null) {
appLogger.w('Cannot retry live stream — missing session info');
showGlobalErrorSnackBar(_lastLogError ?? 'Live stream failed');
_handleBackButton();
@@ -2399,8 +2492,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
/// The current playback position as an absolute epoch second (for live TV time-shift).
int get _currentPositionEpoch =>
(_streamStartEpoch + (player?.state.position.inSeconds ?? 0)).round();
int get _currentPositionEpoch => (_streamStartEpoch + (player?.state.position.inSeconds ?? 0)).round();
/// Show "Watch from Start" / "Watch Live" dialog.
/// Returns true if user chose "Watch from start", false for "Watch Live", null if dismissed.
@@ -2419,12 +2511,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
/// Seek the live TV stream to an absolute epoch second.
/// Creates a new transcode session at the target offset.
Future<void> _seekLivePosition(int targetEpochSeconds) async {
if (_captureBuffer == null || _liveSessionPath == null || _liveSessionIdentifier == null || _transcodeSessionId == null) return;
if (_captureBuffer == null ||
_liveSessionPath == null ||
_liveSessionIdentifier == null ||
_transcodeSessionId == null) {
return;
}
final clamped = targetEpochSeconds.clamp(
_captureBuffer!.seekableStartEpoch,
_captureBuffer!.seekableEndEpoch,
);
final clamped = targetEpochSeconds.clamp(_captureBuffer!.seekableStartEpoch, _captureBuffer!.seekableEndEpoch);
final offsetSeconds = clamped - _captureBuffer!.startedAt.round();
@@ -2446,11 +2540,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_livePlaybackStartTime = DateTime.now();
await _setLiveStreamOptions();
await player!.open(
Media(streamUrl, headers: const {'Accept-Language': 'en'}),
play: true,
isLive: true,
);
await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
if (mounted) setState(() {});
}
+6 -1
View File
@@ -64,6 +64,11 @@ class PlatformDetector {
return platform == TargetPlatform.iOS || platform == TargetPlatform.android;
}
/// Detects if running on a handheld mobile device (phone or tablet, not TV).
static bool isHandheld(BuildContext context) {
return isMobile(context) && !isTV();
}
/// Detects if running on a desktop platform (Windows, macOS, or Linux)
static bool isDesktop(BuildContext context) {
return !isMobile(context);
@@ -86,6 +91,6 @@ class PlatformDetector {
/// Detects if the device is a phone (mobile but not tablet)
static bool isPhone(BuildContext context) {
return isMobile(context) && !isTablet(context);
return isHandheld(context) && !isTablet(context);
}
}