From ffc20b26d410e5811d82d813b8fd7439228d76c4 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 9 May 2026 14:31:12 +0200 Subject: [PATCH] fix(player): block tv background media resume close #990 --- android/app/src/main/AndroidManifest.xml | 1 + .../kotlin/com/edde746/plezy/MainActivity.kt | 34 ++++++++ lib/screens/video_player/parts/lifecycle.dart | 6 ++ .../video_player/parts/media_controls.dart | 84 ++++++++++++++++++- .../video_player/parts/playback_services.dart | 24 ++++++ lib/screens/video_player_screen.dart | 13 ++- lib/services/app_foreground_service.dart | 19 +++++ lib/services/media_controls_manager.dart | 20 +++++ pubspec.lock | 4 +- pubspec.yaml | 4 +- 10 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 lib/services/app_foreground_service.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 03014d35..312c8660 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ + diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index a14e1c2c..dbc46723 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -1,5 +1,6 @@ package com.edde746.plezy +import android.app.ActivityManager import android.app.AppOpsManager import android.app.PictureInPictureParams import android.content.Context @@ -40,6 +41,7 @@ class MainActivity : FlutterActivity() { private val THEME_CHANNEL = "com.plezy/theme" private val DEVICE_CHANNEL = "com.plezy/device" private val APP_EXIT_CHANNEL = "com.plezy/app_exit" + private val APP_FOREGROUND_CHANNEL = "com.plezy/app_foreground" private var watchNextPlugin: WatchNextPlugin? = null // Auto PiP state @@ -219,6 +221,13 @@ class MainActivity : FlutterActivity() { } } + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, APP_FOREGROUND_CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + "requestForeground" -> result.success(requestForeground()) + else -> result.notImplemented() + } + } + // External player: open local video files with proper content:// URIs MethodChannel(flutterEngine.dartExecutor.binaryMessenger, EXTERNAL_PLAYER_CHANNEL).setMethodCallHandler { call, result -> when (call.method) { @@ -364,6 +373,31 @@ class MainActivity : FlutterActivity() { } } + private fun requestForeground(): Boolean = try { + val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + activityManager.moveTaskToFront(taskId, 0) + true + } catch (e: Exception) { + Log.w(TAG, "Failed to move task to foreground", e) + try { + val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply { + addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + if (launchIntent != null) { + startActivity(launchIntent) + true + } else { + false + } + } catch (launchError: Exception) { + Log.w(TAG, "Failed to start foreground activity", launchError) + false + } + } + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) flutterEngine?.let { engine -> diff --git a/lib/screens/video_player/parts/lifecycle.dart b/lib/screens/video_player/parts/lifecycle.dart index ddfbf44e..859afed4 100644 --- a/lib/screens/video_player/parts/lifecycle.dart +++ b/lib/screens/video_player/parts/lifecycle.dart @@ -26,6 +26,8 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { 'pipActive': pipActive, 'pipTransitionInFlight': _androidAutoPipTransitionInFlight, 'hiddenForBackground': _hiddenForBackground, + 'mediaControlsSuspendedForTvBackground': _mediaControlsSuspendedForTvBackground, + 'pendingForegroundMediaResume': _resumeFromSuspendedMediaControlOnForeground, 'backend': _playerBackendLabel, }; if (action != null) { @@ -44,6 +46,8 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { ' pipActive=$pipActive' ' pipTransitionInFlight=$_androidAutoPipTransitionInFlight' ' hiddenForBackground=$_hiddenForBackground' + ' mediaControlsSuspendedForTvBackground=$_mediaControlsSuspendedForTvBackground' + ' pendingForegroundMediaResume=$_resumeFromSuspendedMediaControlOnForeground' ' backend=$_playerBackendLabel', ); } @@ -105,6 +109,7 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { _suspendLiveTimelineForBackground(); if (isTv) { + await _suspendMediaControlsForTvBackground('hidden'); _recordLifecycleState('hidden', action: 'tv_background_pause_only'); return; } @@ -138,6 +143,7 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { // Restore media controls and wakelock when app is resumed. if (_isPlayerInitialized && mounted) { + _resumeMediaControlsAfterTvBackground('app_resumed'); await _restoreMediaControlsAfterResume(); } diff --git a/lib/screens/video_player/parts/media_controls.dart b/lib/screens/video_player/parts/media_controls.dart index fd87337c..0c9340d7 100644 --- a/lib/screens/video_player/parts/media_controls.dart +++ b/lib/screens/video_player/parts/media_controls.dart @@ -1,7 +1,78 @@ part of '../../video_player_screen.dart'; extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState { + bool get _shouldSuspendMediaControlsForTvBackground => + Platform.isAndroid && PlatformDetector.isTV() && !_shouldSkipForPip; + + Future _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'); + } + + bool _consumePendingTvBackgroundMediaControlResume() { + final shouldResume = _resumeFromSuspendedMediaControlOnForeground; + _resumeFromSuspendedMediaControlOnForeground = false; + _tvBackgroundMediaControlResumeTimer?.cancel(); + _tvBackgroundMediaControlResumeTimer = null; + return shouldResume; + } + + Future _requestForegroundResumeFromSuspendedMediaControl(String eventLabel) async { + if (!_mediaControlsSuspendedForTvBackground) return; + + _resumeFromSuspendedMediaControlOnForeground = true; + _tvBackgroundMediaControlResumeTimer?.cancel(); + _tvBackgroundMediaControlResumeTimer = Timer(const Duration(seconds: 8), () { + _tvBackgroundMediaControlResumeTimer = null; + if (!mounted || !_mediaControlsSuspendedForTvBackground) return; + _resumeFromSuspendedMediaControlOnForeground = false; + appLogger.d('Media control: deferred TV foreground resume expired before app resumed'); + unawaited( + Sentry.addBreadcrumb( + Breadcrumb( + message: 'TV media control foreground resume expired', + category: 'player.media_controls', + data: {'event': eventLabel}, + ), + ), + ); + }); + + unawaited( + Sentry.addBreadcrumb( + Breadcrumb( + message: 'TV media control requested foreground resume', + category: 'player.media_controls', + data: {'event': eventLabel}, + ), + ), + ); + + final foregrounded = await AppForegroundService.requestForeground(); + appLogger.d('Media control: requested app foreground for $eventLabel (success=$foregrounded)'); + if (!foregrounded && mounted && _mediaControlsSuspendedForTvBackground) { + _consumePendingTvBackgroundMediaControlResume(); + } + } + Future _syncMediaControlsAvailability() async { + if (_mediaControlsSuspendedForTvBackground) return; + final manager = _mediaControlsManager; final currentPlayer = player; if (!mounted || manager == null || currentPlayer == null) return; @@ -28,6 +99,8 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState { Future _restoreMediaControlsAfterResume() async { if (!_isPlayerInitialized || !mounted) return; + final resumeRequestedByMediaControl = _consumePendingTvBackgroundMediaControlResume(); + unawaited(_setWakelock(player?.state.isActive ?? false)); final manager = _mediaControlsManager; @@ -44,13 +117,17 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState { if (!mounted || currentPlayer != player || currentPlayer == null) return; - if (_wasPlayingBeforeInactive) { + final wasPlayingBeforeInactive = _wasPlayingBeforeInactive; + if (wasPlayingBeforeInactive || resumeRequestedByMediaControl) { + final resumeReason = resumeRequestedByMediaControl + ? 'TV media control foreground request' + : 'returning from inactive state'; try { await _seekBackForRewind(currentPlayer); await currentPlayer.play(); - appLogger.d('Video resumed after returning from inactive state'); + appLogger.d('Video resumed after $resumeReason'); } catch (e) { - appLogger.w('Failed to resume playback after returning from inactive state', error: e); + appLogger.w('Failed to resume playback after $resumeReason', error: e); } finally { _wasPlayingBeforeInactive = false; } @@ -62,6 +139,7 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState { /// Wrapper method to update media controls playback state void _updateMediaControlsPlaybackState() { + if (_mediaControlsSuspendedForTvBackground) return; if (player == null) return; _mediaControlsManager?.updatePlaybackState( diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 52635a7f..10c1de17 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -89,6 +89,17 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { // Set up media control event handling _mediaControlSubscription = _mediaControlsManager!.controlEvents.listen((event) { final currentPlayer = player; + if (_mediaControlsSuspendedForTvBackground) { + final eventLabel = event.runtimeType.toString(); + if (currentPlayer != null && (event is PlayEvent || event is TogglePlayPauseEvent)) { + appLogger.d('Media control: $eventLabel received while Android TV background-suspended'); + unawaited(_requestForegroundResumeFromSuspendedMediaControl(eventLabel)); + } else { + appLogger.d('Media control: $eventLabel ignored while Android TV background-suspended'); + } + return; + } + if (currentPlayer == null && event is! NextTrackEvent && event is! PreviousTrackEvent) return; if (event is PlayEvent) { @@ -175,6 +186,19 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { } void _onPlayingStateChanged(bool isPlaying) { + if (isPlaying && _mediaControlsSuspendedForTvBackground) { + 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'), + ); + final currentPlayer = player; + if (currentPlayer != null) { + unawaited(currentPlayer.pause()); + } + unawaited(_setWakelock(false)); + return; + } + _setWakelock(isPlaying); if (isPlaying) { diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 202e0281..4f4e0707 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -40,6 +40,7 @@ import '../services/discord_rpc_service.dart'; import '../services/trackers/tracker_coordinator.dart'; import '../services/trakt/trakt_scrobble_service.dart'; import '../services/episode_navigation_service.dart'; +import '../services/app_foreground_service.dart'; import '../services/media_controls_manager.dart'; import '../services/playback_initialization_service.dart'; import '../services/playback_progress_tracker.dart'; @@ -339,6 +340,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin // App lifecycle state tracking bool _wasPlayingBeforeInactive = false; bool _hiddenForBackground = false; + bool _mediaControlsSuspendedForTvBackground = false; + bool _resumeFromSuspendedMediaControlOnForeground = false; bool _autoPipEnabled = false; bool _androidAutoPipTransitionInFlight = false; bool _pipFiltersPrepared = false; @@ -347,6 +350,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin Future _lifecycleTransition = Future.value(); String _playerBackendLabel = 'unknown'; Future? _stoppedProgressFuture; + Timer? _tvBackgroundMediaControlResumeTimer; /// Whether to skip lifecycle actions because PiP is active or about to start. /// Apple auto-PiP is system-initiated during the background transition, and @@ -513,8 +517,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin break; } // We don't support background playback - _mediaControlsManager?.clear(); - _setWakelock(false); + if (_shouldSuspendMediaControlsForTvBackground) { + unawaited(_suspendMediaControlsForTvBackground('paused')); + } else { + unawaited(_mediaControlsManager?.clear()); + } + unawaited(_setWakelock(false)); _recordLifecycleState('paused', action: 'backgrounded'); break; case AppLifecycleState.resumed: @@ -1014,6 +1022,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _serverStatusSubscription?.cancel(); _autoPlayTimer?.cancel(); + _tvBackgroundMediaControlResumeTimer?.cancel(); _stillWatchingTimer?.cancel(); diff --git a/lib/services/app_foreground_service.dart b/lib/services/app_foreground_service.dart new file mode 100644 index 00000000..8caff2d3 --- /dev/null +++ b/lib/services/app_foreground_service.dart @@ -0,0 +1,19 @@ +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; + +class AppForegroundService { + static const MethodChannel _channel = MethodChannel('com.plezy/app_foreground'); + + static Future requestForeground() async { + if (!Platform.isAndroid) return false; + + try { + return await _channel.invokeMethod('requestForeground') ?? false; + } on MissingPluginException { + return false; + } on PlatformException { + return false; + } + } +} diff --git a/lib/services/media_controls_manager.dart b/lib/services/media_controls_manager.dart index f80dd0a6..d16d43f0 100644 --- a/lib/services/media_controls_manager.dart +++ b/lib/services/media_controls_manager.dart @@ -24,6 +24,7 @@ class MediaControlsManager { bool? _lastCanGoNext; bool? _lastCanGoPrevious; bool? _lastCanSeek; + bool _updatesSuspended = false; MediaControlsManager() { _throttledUpdate = throttle( @@ -41,6 +42,8 @@ class MediaControlsManager { /// shape (Plex's `/photo/:/transcode` proxy vs. Jellyfin's /// self-authenticated image URL). Future updateMetadata({required MediaItem metadata, MediaServerClient? client, Duration? duration}) async { + if (_updatesSuspended) return; + try { String? artworkUrl; if (client != null && metadata.thumbPath != null) { @@ -77,6 +80,8 @@ class MediaControlsManager { required double speed, bool force = false, }) async { + if (_updatesSuspended) return; + final params = _PlaybackStateParams(isPlaying: isPlaying, position: position, speed: speed); if (force) { @@ -110,6 +115,8 @@ class MediaControlsManager { /// - Playlist items: Enable based on playlist position /// - Movies: Usually disabled Future setControlsEnabled({bool canGoNext = false, bool canGoPrevious = false, bool canSeek = false}) async { + if (_updatesSuspended) return; + try { final controlsToEnable = []; final controlsToDisable = []; @@ -158,6 +165,19 @@ class MediaControlsManager { } } + void suspendUpdates() { + if (_updatesSuspended) return; + _updatesSuspended = true; + _throttledUpdate.cancel(); + appLogger.d('Media controls updates suspended'); + } + + void resumeUpdates() { + if (!_updatesSuspended) return; + _updatesSuspended = false; + appLogger.d('Media controls updates resumed'); + } + /// Dispose resources void dispose() { _throttledUpdate.cancel(); diff --git a/pubspec.lock b/pubspec.lock index 8d0c7063..e5120c6f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -735,8 +735,8 @@ packages: dependency: "direct main" description: path: "." - ref: edfe336 - resolved-ref: edfe33636bc82c0649c808117651b5c6844cce80 + ref: "5ddd27c" + resolved-ref: "5ddd27c2acacca31060d288db1371a870602cf46" url: "https://github.com/edde746/os-media-controls" source: git version: "0.2.1" diff --git a/pubspec.yaml b/pubspec.yaml index 038d19ca..b943471d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: plezy description: "A beautiful Plex client for Flutter" publish_to: "none" -version: 2.0.0+84 +version: 2.0.0+85 environment: sdk: ">=3.10.7 <4.0.0" @@ -31,7 +31,7 @@ dependencies: os_media_controls: git: url: https://github.com/edde746/os-media-controls - ref: edfe336 + ref: 5ddd27c rate_limiter: ^1.0.0 wakelock_plus: git: