fix(player): block tv background media resume

close #990
This commit is contained in:
edde746
2026-05-09 14:37:00 +02:00
parent 3a612df3d3
commit ffc20b26d4
10 changed files with 200 additions and 9 deletions
+1
View File
@@ -10,6 +10,7 @@
<!-- PIP and media session foreground service permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.REORDER_TASKS"/>
<!-- Background download notifications and foreground service -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
@@ -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 ->
@@ -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();
}
@@ -1,7 +1,78 @@
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');
}
bool _consumePendingTvBackgroundMediaControlResume() {
final shouldResume = _resumeFromSuspendedMediaControlOnForeground;
_resumeFromSuspendedMediaControlOnForeground = false;
_tvBackgroundMediaControlResumeTimer?.cancel();
_tvBackgroundMediaControlResumeTimer = null;
return shouldResume;
}
Future<void> _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<void> _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<void> _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(
@@ -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) {
+11 -2
View File
@@ -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<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
Future<void> _lifecycleTransition = Future<void>.value();
String _playerBackendLabel = 'unknown';
Future<void>? _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<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
_serverStatusSubscription?.cancel();
_autoPlayTimer?.cancel();
_tvBackgroundMediaControlResumeTimer?.cancel();
_stillWatchingTimer?.cancel();
+19
View File
@@ -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<bool> requestForeground() async {
if (!Platform.isAndroid) return false;
try {
return await _channel.invokeMethod<bool>('requestForeground') ?? false;
} on MissingPluginException {
return false;
} on PlatformException {
return false;
}
}
}
+20
View File
@@ -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<void> 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<void> setControlsEnabled({bool canGoNext = false, bool canGoPrevious = false, bool canSeek = false}) async {
if (_updatesSuspended) return;
try {
final controlsToEnable = <MediaControl>[];
final controlsToDisable = <MediaControl>[];
@@ -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();
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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: