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: