@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user