fix: resolve Jellyfin, logout, playback, and Android regressions

This commit is contained in:
edde746
2026-07-09 17:14:45 +02:00
parent a4a2942546
commit 5867809560
22 changed files with 243 additions and 166 deletions
+50
View File
@@ -139,6 +139,56 @@ jobs:
echo "No tests found, skipping test execution" echo "No tests found, skipping test execution"
fi fi
android-test:
name: Android JVM Unit Tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: "17"
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.44.0"
cache: true
pub-cache: false
- name: Cache Pub dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install dependencies
run: flutter pub get
- name: Configure Android local properties
run: printf 'flutter.sdk=%s\nsdk.dir=%s\n' "$FLUTTER_ROOT" "$ANDROID_HOME" > android/local.properties
- name: Run Android JVM unit tests
working-directory: android
run: ./gradlew :app:testDebugUnitTest :saf_util:testDebugUnitTest :libass:testDebugUnitTest -x :app:compileFlutterBuildDebug --continue
native-format: native-format:
name: Native Formatting name: Native Formatting
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -89,7 +89,6 @@ class MainActivity : FlutterActivity() {
private val DEVICE_ADJUSTMENT_CHANNEL = "com.plezy/device_adjustment" private val DEVICE_ADJUSTMENT_CHANNEL = "com.plezy/device_adjustment"
private val TEXT_INPUT_CHANNEL = "com.plezy/text_input" private val TEXT_INPUT_CHANNEL = "com.plezy/text_input"
private val APP_EXIT_CHANNEL = "com.plezy/app_exit" private val APP_EXIT_CHANNEL = "com.plezy/app_exit"
private val APP_FOREGROUND_CHANNEL = "com.plezy/app_foreground"
private var watchNextPlugin: WatchNextPlugin? = null private var watchNextPlugin: WatchNextPlugin? = null
private var nativeTextInputFocused = false private var nativeTextInputFocused = false
private var pendingExternalPlayerResult: MethodChannel.Result? = null private var pendingExternalPlayerResult: MethodChannel.Result? = null
@@ -532,13 +531,6 @@ 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 // External player: open local video files with proper content:// URIs
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, EXTERNAL_PLAYER_CHANNEL).setMethodCallHandler { call, result -> MethodChannel(flutterEngine.dartExecutor.binaryMessenger, EXTERNAL_PLAYER_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
@@ -723,31 +715,6 @@ 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
}
}
private fun handleDeviceAdjustmentCall(method: String, arguments: Any?, result: MethodChannel.Result) { private fun handleDeviceAdjustmentCall(method: String, arguments: Any?, result: MethodChannel.Result) {
try { try {
when (method) { when (method) {
@@ -84,6 +84,7 @@ interface ExoPlayerDelegate : com.edde746.plezy.shared.PlayerDelegate {
uri: String, uri: String,
headers: Map<String, String>?, headers: Map<String, String>?,
positionMs: Long, positionMs: Long,
playWhenReady: Boolean,
errorMessage: String errorMessage: String
): Boolean = false ): Boolean = false
} }
@@ -1110,6 +1111,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
uri = currentMediaUri!!, uri = currentMediaUri!!,
headers = currentHeaders, headers = currentHeaders,
positionMs = effectivePosition, positionMs = effectivePosition,
playWhenReady = exoPlayer?.playWhenReady ?: true,
errorMessage = "Video track present but no decoder available" errorMessage = "Video track present but no decoder available"
) )
return return
@@ -1217,6 +1219,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
uri = currentMediaUri!!, uri = currentMediaUri!!,
headers = currentHeaders, headers = currentHeaders,
positionMs = effectivePosition, positionMs = effectivePosition,
playWhenReady = exoPlayer?.playWhenReady ?: true,
errorMessage = error.message ?: "Unknown error" errorMessage = error.message ?: "Unknown error"
) ?: false ) ?: false
@@ -2650,6 +2653,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
uri = uri, uri = uri,
headers = currentHeaders, headers = currentHeaders,
positionMs = effectivePosition, positionMs = effectivePosition,
playWhenReady = player.playWhenReady,
errorMessage = "Decoder hang: $decoderName accepted input but produced no output" errorMessage = "Decoder hang: $decoderName accepted input but produced no output"
) )
} }
@@ -2700,6 +2704,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
uri = uri, uri = uri,
headers = currentHeaders, headers = currentHeaders,
positionMs = player.currentPosition, positionMs = player.currentPosition,
playWhenReady = player.playWhenReady,
errorMessage = "Video track present but no decoder available" errorMessage = "Video track present but no decoder available"
) )
return return
@@ -2715,6 +2720,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
uri = uri, uri = uri,
headers = currentHeaders, headers = currentHeaders,
positionMs = player.currentPosition, positionMs = player.currentPosition,
playWhenReady = player.playWhenReady,
errorMessage = "Black screen detected: 0 video frames rendered after ${elapsed}ms" errorMessage = "Black screen detected: 0 video frames rendered after ${elapsed}ms"
) )
return return
@@ -3722,8 +3728,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
fun triggerFallback() { fun triggerFallback() {
val uri = currentMediaUri ?: return val uri = currentMediaUri ?: return
val pos = exoPlayer?.currentPosition ?: 0L val player = exoPlayer
delegate?.onFormatUnsupported(uri, currentHeaders, pos, "debug: manual fallback trigger") val pos = player?.currentPosition ?: 0L
delegate?.onFormatUnsupported(uri, currentHeaders, pos, player?.playWhenReady ?: true, "debug: manual fallback trigger")
} }
// Cleanup // Cleanup
@@ -302,9 +302,7 @@ class ExoPlayerPlugin :
options.add("sid=no") options.add("sid=no")
options.add("secondary-sid=no") options.add("secondary-sid=no")
appendExternalSubtitleOptions(options, externalSubtitleSnapshot) appendExternalSubtitleOptions(options, externalSubtitleSnapshot)
headers?.forEach { (key, value) -> appendHttpHeaderOptions(options, headers)
options.add("http-header-fields-append=$key: $value")
}
val optionsStr = options.joinToString(",") val optionsStr = options.joinToString(",")
// Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads) // Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads)
val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri
@@ -827,6 +825,16 @@ class ExoPlayerPlugin :
private fun escapeMpvPathListEntry(value: String): String = value.replace("\\", "\\\\").replace(":", "\\:") private fun escapeMpvPathListEntry(value: String): String = value.replace("\\", "\\\\").replace(":", "\\:")
private fun appendHttpHeaderOptions(options: MutableList<String>, headers: Map<String, String>?) {
if (headers.isNullOrEmpty()) return
options.add("http-header-fields-clr=")
headers.forEach { (key, value) ->
val header = "$key: $value"
options.add("http-header-fields-append=%${header.toByteArray(Charsets.UTF_8).size}%$header")
}
}
/** /**
* Configure a freshly initialized MPV fallback core: replay the properties * Configure a freshly initialized MPV fallback core: replay the properties
* and observers Dart registered against the ExoPlayer session, then resume * and observers Dart registered against the ExoPlayer session, then resume
@@ -839,7 +847,8 @@ class ExoPlayerPlugin :
uri: String, uri: String,
headers: Map<String, String>?, headers: Map<String, String>?,
positionMs: Long, positionMs: Long,
externalSubtitles: List<Map<String, Any?>>? externalSubtitles: List<Map<String, Any?>>?,
playWhenReady: Boolean
) { ) {
// Snapshot Dart-registered state on main thread before clearing // Snapshot Dart-registered state on main thread before clearing
val pendingProps = pendingMpvProperties.toList() val pendingProps = pendingMpvProperties.toList()
@@ -886,12 +895,11 @@ class ExoPlayerPlugin :
val startSeconds = positionMs / 1000.0 val startSeconds = positionMs / 1000.0
val options = mutableListOf<String>() val options = mutableListOf<String>()
options.add(if (positionMs > 0L) "start=$startSeconds" else "start=none") options.add(if (positionMs > 0L) "start=$startSeconds" else "start=none")
if (!playWhenReady) options.add("pause=yes")
options.add("sid=no") options.add("sid=no")
options.add("secondary-sid=no") options.add("secondary-sid=no")
appendExternalSubtitleOptions(options, externalSubtitles) appendExternalSubtitleOptions(options, externalSubtitles)
headers?.forEach { (key, value) -> appendHttpHeaderOptions(options, headers)
options.add("http-header-fields-append=$key: $value")
}
val optionsStr = options.joinToString(",") val optionsStr = options.joinToString(",")
notifyBackendSwitched() notifyBackendSwitched()
core.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) core.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr))
@@ -919,6 +927,7 @@ class ExoPlayerPlugin :
uri: String, uri: String,
headers: Map<String, String>?, headers: Map<String, String>?,
positionMs: Long, positionMs: Long,
playWhenReady: Boolean,
errorMessage: String errorMessage: String
): Boolean { ): Boolean {
if (usingMpvFallback || fallbackInProgress) { if (usingMpvFallback || fallbackInProgress) {
@@ -993,7 +1002,7 @@ class ExoPlayerPlugin :
usingMpvFallback = true usingMpvFallback = true
fallbackInProgress = false fallbackInProgress = false
setupMpvFallback(core, act, uri, headers, positionMs, fallbackExternalSubtitles) setupMpvFallback(core, act, uri, headers, positionMs, fallbackExternalSubtitles, playWhenReady)
} }
} catch (e: Exception) { } catch (e: Exception) {
fallbackInProgress = false fallbackInProgress = false
+6 -1
View File
@@ -60,7 +60,12 @@ Future<PlexAccountRegistration> registerPlexAccountFromToken({
createdAt: DateTime.now(), createdAt: DateTime.now(),
lastAuthenticatedAt: DateTime.now(), lastAuthenticatedAt: DateTime.now(),
); );
final existedBefore = await connections.get(connection.id) != null; final legacyId = 'plex.${auth.clientIdentifier}';
final existedBefore =
await connections.get(connection.id) != null ||
(accountUuid.isNotEmpty &&
legacyId != connection.id &&
await connections.get(legacyId) is PlexAccountConnection);
await connections.upsert(connection); await connections.upsert(connection);
if (accountUuid.isNotEmpty) { if (accountUuid.isNotEmpty) {
+4
View File
@@ -27,6 +27,10 @@ extension DownloadDatabaseOperations on AppDatabase {
await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId))).go(); await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId))).go();
} }
Future<void> clearAllDownloadOwners() async {
await delete(downloadOwners).go();
}
Future<Set<String>> getDownloadOwnerKeysForProfile(String profileId) async { Future<Set<String>> getDownloadOwnerKeysForProfile(String profileId) async {
if (profileId.isEmpty) return const {}; if (profileId.isEmpty) return const {};
final rows = await (select(downloadOwners)..where((t) => t.profileId.equals(profileId))).get(); final rows = await (select(downloadOwners)..where((t) => t.profileId.equals(profileId))).get();
+1 -1
View File
@@ -166,7 +166,7 @@ abstract class LiveTvSupport {
/// Persist the favorites list (and order, where supported). Plex pushes /// Persist the favorites list (and order, where supported). Plex pushes
/// to its cloud sync endpoint; Jellyfin POSTs/DELETEs the /// to its cloud sync endpoint; Jellyfin POSTs/DELETEs the
/// `/Users/{userId}/FavoriteItems/{channelId}` flag and saves the order /// `/UserFavoriteItems/{channelId}?userId=...` flag and saves the order
/// locally. /// locally.
Future<void> setFavoriteChannels(List<FavoriteChannel> channels); Future<void> setFavoriteChannels(List<FavoriteChannel> channels);
+1 -1
View File
@@ -61,7 +61,7 @@ class ServerCapabilities {
final bool numericUserRating; final bool numericUserRating;
/// Per-user favorite flag ("heart") on media items. Jellyfin exposes it via /// Per-user favorite flag ("heart") on media items. Jellyfin exposes it via
/// `/Users/{userId}/FavoriteItems/{itemId}`; Plex has no equivalent. /// `/UserFavoriteItems/{itemId}?userId=...`; Plex has no equivalent.
final bool userFavorites; final bool userFavorites;
/// Hide an item from Continue Watching without changing watch state or /// Hide an item from Continue Watching without changing watch state or
+25
View File
@@ -194,6 +194,31 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
await _releaseDownloadsForProfileWhere(profileId, (_) => true); await _releaseDownloadsForProfileWhere(profileId, (_) => true);
} }
Future<void> deleteAllDownloads() async {
final downloads = await _downloadManager.getAllDownloads();
for (final row in downloads) {
await _downloadManager.deleteDownload(row.globalKey);
}
await _database.clearAllDownloadOwners();
try {
final artworkDirectory = await DownloadStorageService.instance.getArtworkDirectory();
if (await artworkDirectory.exists()) {
await artworkDirectory.delete(recursive: true);
}
} catch (e, stackTrace) {
appLogger.w('Failed to delete shared download artwork directory', error: e, stackTrace: stackTrace);
}
_downloads.clear();
_metadata.clear();
_artworkPaths.clear();
_queueing.clear();
_ownedDownloadKeys.clear();
_deletionProgress.clear();
safeNotifyListeners();
}
/// Remove ownership rows for [profileId] that belong to the removed /// Remove ownership rows for [profileId] that belong to the removed
/// connection's public server ids. Physical files stay when any other valid /// connection's public server ids. Physical files stay when any other valid
/// owner remains. /// owner remains.
+4 -3
View File
@@ -230,6 +230,7 @@ Future<void> logoutAllProfiles(BuildContext context) async {
await companionRemote.resetForLogout(); await companionRemote.resetForLogout();
await userProfileProvider.logout(); await userProfileProvider.logout();
await scope.downloads.deleteAllDownloads();
scope.multiServer.clearAllConnections(); scope.multiServer.clearAllConnections();
// Drop the profile/connection rows so the next sign-in starts clean and // Drop the profile/connection rows so the next sign-in starts clean and
// doesn't bind to stale tokens or orphaned profile rows. // doesn't bind to stale tokens or orphaned profile rows.
@@ -245,9 +246,9 @@ Future<void> logoutAllProfiles(BuildContext context) async {
// through the next sign-in's clients). // through the next sign-in's clients).
await scope.database.clearAllWatchActions(); await scope.database.clearAllWatchActions();
await scope.database.clearAllSyncRules(); await scope.database.clearAllSyncRules();
// The API cache is app-global and Plex rows are keyed by server only, so // Downloads were removed above, so no pinned cache rows need to survive this
// a later sign-in as a different user must not inherit them. // app-global logout into the next sign-in.
await ApiCache.instance.clearVolatile(); await ApiCache.instance.clearAll();
await scope.hiddenLibraries?.refresh(); await scope.hiddenLibraries?.refresh();
playbackState.clearShuffle(); playbackState.clearShuffle();
@@ -28,7 +28,6 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
'hiddenForBackground': _hiddenForBackground, 'hiddenForBackground': _hiddenForBackground,
'playerSuspendedForTvBackground': _playerSuspendedForTvBackground, 'playerSuspendedForTvBackground': _playerSuspendedForTvBackground,
'mediaControlsSuspendedForTvBackground': _mediaControlsSuspendedForTvBackground, 'mediaControlsSuspendedForTvBackground': _mediaControlsSuspendedForTvBackground,
'pendingForegroundMediaResume': _resumeFromSuspendedMediaControlOnForeground,
'backend': _playerBackendLabel, 'backend': _playerBackendLabel,
}; };
if (action != null) { if (action != null) {
@@ -49,7 +48,6 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
' hiddenForBackground=$_hiddenForBackground' ' hiddenForBackground=$_hiddenForBackground'
' playerSuspendedForTvBackground=$_playerSuspendedForTvBackground' ' playerSuspendedForTvBackground=$_playerSuspendedForTvBackground'
' mediaControlsSuspendedForTvBackground=$_mediaControlsSuspendedForTvBackground' ' mediaControlsSuspendedForTvBackground=$_mediaControlsSuspendedForTvBackground'
' pendingForegroundMediaResume=$_resumeFromSuspendedMediaControlOnForeground'
' backend=$_playerBackendLabel', ' backend=$_playerBackendLabel',
); );
} }
@@ -24,52 +24,6 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState {
_recordLifecycleState('media_controls', action: 'resumed:$reason'); _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 { Future<void> _syncMediaControlsAvailability() async {
if (_mediaControlsSuspendedForTvBackground) return; if (_mediaControlsSuspendedForTvBackground) return;
@@ -104,8 +58,6 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState {
Future<void> _restoreMediaControlsAfterResume() async { Future<void> _restoreMediaControlsAfterResume() async {
if (!_isPlayerInitialized || !mounted) return; if (!_isPlayerInitialized || !mounted) return;
final resumeRequestedByMediaControl = _consumePendingTvBackgroundMediaControlResume();
unawaited(_setWakelock(player?.state.isActive ?? false)); unawaited(_setWakelock(player?.state.isActive ?? false));
final manager = _mediaControlsManager; final manager = _mediaControlsManager;
@@ -123,16 +75,13 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState {
if (!mounted || currentPlayer != player || currentPlayer == null) return; if (!mounted || currentPlayer != player || currentPlayer == null) return;
final wasPlayingBeforeInactive = _wasPlayingBeforeInactive; final wasPlayingBeforeInactive = _wasPlayingBeforeInactive;
if (wasPlayingBeforeInactive || resumeRequestedByMediaControl) { if (wasPlayingBeforeInactive) {
final resumeReason = resumeRequestedByMediaControl
? 'TV media control foreground request'
: 'returning from inactive state';
try { try {
await _seekBackForRewind(currentPlayer); await _seekBackForRewind(currentPlayer);
await _playWithPlaybackIntent(currentPlayer); await _playWithPlaybackIntent(currentPlayer);
appLogger.d('Video resumed after $resumeReason'); appLogger.d('Video resumed after returning from inactive state');
} catch (e) { } catch (e) {
appLogger.w('Failed to resume playback after $resumeReason', error: e); appLogger.w('Failed to resume playback after returning from inactive state', error: e);
} finally { } finally {
_wasPlayingBeforeInactive = false; _wasPlayingBeforeInactive = false;
} }
@@ -294,13 +294,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
_mediaControlSubscription = mediaControlsManager.controlEvents.listen((event) { _mediaControlSubscription = mediaControlsManager.controlEvents.listen((event) {
final activePlayer = player; final activePlayer = player;
if (_mediaControlsSuspendedForTvBackground) { if (_mediaControlsSuspendedForTvBackground) {
final eventLabel = event.runtimeType.toString(); appLogger.d('Media control: ${event.runtimeType} ignored while Android TV background-suspended');
if (activePlayer != 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; return;
} }
-4
View File
@@ -44,7 +44,6 @@ import '../services/discord_rpc_service.dart';
import '../services/trackers/tracker_coordinator.dart'; import '../services/trackers/tracker_coordinator.dart';
import '../services/trakt/trakt_scrobble_service.dart'; import '../services/trakt/trakt_scrobble_service.dart';
import '../services/episode_navigation_service.dart'; import '../services/episode_navigation_service.dart';
import '../services/app_foreground_service.dart';
import '../services/apple_tv_remote_touch_service.dart'; import '../services/apple_tv_remote_touch_service.dart';
import '../services/media_controls_manager.dart'; import '../services/media_controls_manager.dart';
import '../services/playback_coordinator.dart'; import '../services/playback_coordinator.dart';
@@ -378,7 +377,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
bool _wasPlayingBeforeInactive = false; bool _wasPlayingBeforeInactive = false;
bool _hiddenForBackground = false; bool _hiddenForBackground = false;
bool _mediaControlsSuspendedForTvBackground = false; bool _mediaControlsSuspendedForTvBackground = false;
bool _resumeFromSuspendedMediaControlOnForeground = false;
bool _resumeAfterAppleAudioSessionPause = false; bool _resumeAfterAppleAudioSessionPause = false;
DateTime? _lastPlaybackPauseAt; DateTime? _lastPlaybackPauseAt;
bool _autoPipEnabled = false; bool _autoPipEnabled = false;
@@ -389,7 +387,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
int _rewindOnResume = 0; int _rewindOnResume = 0;
Future<void> _lifecycleTransition = Future<void>.value(); Future<void> _lifecycleTransition = Future<void>.value();
String _playerBackendLabel = 'unknown'; String _playerBackendLabel = 'unknown';
Timer? _tvBackgroundMediaControlResumeTimer;
/// Android TV: release the native AV pipeline once the app stays /// Android TV: release the native AV pipeline once the app stays
/// backgrounded past this grace window. A merely paused player keeps its /// backgrounded past this grace window. A merely paused player keeps its
@@ -1171,7 +1168,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_serverStatusSubscription?.cancel(); _serverStatusSubscription?.cancel();
_autoPlayTimer?.cancel(); _autoPlayTimer?.cancel();
_tvBackgroundMediaControlResumeTimer?.cancel();
_tvBackgroundPlayerSuspendTimer?.cancel(); _tvBackgroundPlayerSuspendTimer?.cancel();
_stillWatchingTimer?.cancel(); _stillWatchingTimer?.cancel();
-19
View File
@@ -1,19 +0,0 @@
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;
}
}
}
@@ -49,8 +49,10 @@ mixin _JellyfinWatchStateMethods on MediaServerCacheMixin {
/// Toggle the per-user `IsFavorite` flag for [itemId]. Backs [setFavorite] /// Toggle the per-user `IsFavorite` flag for [itemId]. Backs [setFavorite]
/// and the live-TV favorite-channel adapter; works on any Jellyfin item. /// and the live-TV favorite-channel adapter; works on any Jellyfin item.
Future<void> _setItemFavorite(String itemId, bool isFavorite) async { Future<void> _setItemFavorite(String itemId, bool isFavorite) async {
final path = '/Users/${_segment(connection.userId)}/FavoriteItems/${_segment(itemId)}'; final path = '/UserFavoriteItems/${_segment(itemId)}';
final response = isFavorite ? await _http.post(path) : await _http.delete(path); final response = isFavorite
? await _http.post(path, queryParameters: {'userId': connection.userId})
: await _http.delete(path, queryParameters: {'userId': connection.userId});
throwIfHttpError(response); throwIfHttpError(response);
} }
} }
@@ -772,6 +772,11 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
if (player.state.completed) { if (player.state.completed) {
// Parked at queue end: restart the current track. // Parked at queue end: restart the current track.
await player.seek(Duration.zero); await player.seek(Duration.zero);
final currentTrack = _currentTrack;
final currentSource = _currentSource;
if (currentTrack != null && currentSource != null) {
_bindTrackServices(currentTrack, currentSource);
}
unawaited(_armNext(_generation)); unawaited(_armNext(_generation));
} }
await player.play(); await player.play();
+6 -5
View File
@@ -8,22 +8,23 @@ int? flexibleInt(Object? v) => switch (v) {
_ => null, _ => null,
}; };
/// Parse a value that may be [bool], [int] (0/1), or [String] ('1') to [bool]. /// Parse a value that may be [bool], [int] (0/1), or [String] ('1'/'true'/'false') to [bool].
/// Returns `false` for `null` or unrecognised values. /// Returns `false` for `null` or unrecognised values.
/// Handles Plex API responses where boolean fields may arrive as integers. /// Handles Plex API responses where boolean fields may arrive as integers.
bool flexibleBool(Object? v) => switch (v) { bool flexibleBool(Object? v) => switch (v) {
final bool b => b, final bool b => b,
final int n => n == 1, final int n => n == 1,
final String s => s == '1', final String s => s == '1' || s.toLowerCase() == 'true',
_ => false, _ => false,
}; };
/// Parse a value that may be [bool], [int] (0/1), or [String] ('1') to [bool]. /// Parse a value that may be [bool], [int] (0/1), or [String] ('1'/'true'/'false') to [bool].
/// Returns `null` for `null` or unrecognised values. /// Returns `null` for `null` or unsupported non-string values; legacy string
/// values other than `'1'`/`'true'` map to `false`.
bool? flexibleBoolNullable(Object? v) => switch (v) { bool? flexibleBoolNullable(Object? v) => switch (v) {
final bool b => b, final bool b => b,
final int n => n == 1, final int n => n == 1,
final String s => s == '1', final String s => s == '1' || s.toLowerCase() == 'true',
_ => null, _ => null,
}; };
@@ -56,6 +56,7 @@ android {
testOptions { testOptions {
unitTests { unitTests {
isIncludeAndroidResources = true isIncludeAndroidResources = true
isReturnDefaultValues = true
all { all {
it.useJUnitPlatform() it.useJUnitPlatform()
@@ -13,6 +13,7 @@ import android.os.Build
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.core.net.toUri
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -20,11 +21,11 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.PluginRegistry
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import androidx.core.net.toUri
/** SafUtilPlugin */ /** SafUtilPlugin */
@@ -37,11 +38,15 @@ class SafUtilPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
private lateinit var context: Context private lateinit var context: Context
private var activity: Activity? = null private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null
private var pendingResult: Result? = null private var pendingResult: Result? = null
private var pendingArguments: PendingArguments? = null private var pendingArguments: PendingArguments? = null
private val requestCodeOpenDocumentTree = 1001 private val requestCodeOpenDocumentTree = 1001
private val requestCodeOpenFiles = 1002 private val requestCodeOpenFiles = 1002
private val activityResultListener = PluginRegistry.ActivityResultListener { requestCode, resultCode, data ->
onActivityResult(requestCode, resultCode, data)
}
/// Atomically takes ownership of the pending picker state. Every reply to a /// Atomically takes ownership of the pending picker state. Every reply to a
/// pending Result must go through this so no already-answered Result is ever /// pending Result must go through this so no already-answered Result is ever
@@ -61,21 +66,31 @@ class SafUtilPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
} }
override fun onDetachedFromActivity() { override fun onDetachedFromActivity() {
activity = null detachFromActivityBinding()
} }
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity attachToActivity(binding)
} }
override fun onAttachedToActivity(binding: ActivityPluginBinding) { override fun onAttachedToActivity(binding: ActivityPluginBinding) {
attachToActivity(binding)
}
private fun attachToActivity(binding: ActivityPluginBinding) {
detachFromActivityBinding()
activityBinding = binding
activity = binding.activity activity = binding.activity
binding.addActivityResultListener { requestCode, resultCode, data -> binding.addActivityResultListener(activityResultListener)
onActivityResult(requestCode, resultCode, data)
}
} }
override fun onDetachedFromActivityForConfigChanges() { override fun onDetachedFromActivityForConfigChanges() {
detachFromActivityBinding()
}
private fun detachFromActivityBinding() {
activityBinding?.removeActivityResultListener(activityResultListener)
activityBinding = null
activity = null activity = null
} }
@@ -1,27 +1,89 @@
package com.fluttercavalry.saf_util package com.fluttercavalry.saf_util
import android.app.Activity
import android.content.Intent
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import org.mockito.Mockito import io.flutter.plugin.common.PluginRegistry
import org.mockito.ArgumentCaptor
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoMoreInteractions
import org.mockito.Mockito.`when`
import kotlin.test.assertEquals
import kotlin.test.Test import kotlin.test.Test
/*
* This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation.
*
* Once you have built the plugin's example app, you can run these tests from the command
* line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or
* you can run them directly from IDEs that support JUnit such as Android Studio.
*/
internal class SafUtilPluginTest { internal class SafUtilPluginTest {
@Test @Test
fun onMethodCall_getPlatformVersion_returnsExpectedValue() { fun onMethodCall_unknownMethod_returnsNotImplemented() {
val plugin = SafUtilPlugin() val plugin = SafUtilPlugin()
val result = mock(MethodChannel.Result::class.java)
val call = MethodCall("getPlatformVersion", null) plugin.onMethodCall(MethodCall("unknown", null), result)
val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java)
plugin.onMethodCall(call, mockResult)
Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) verify(result).notImplemented()
verifyNoMoreInteractions(result)
}
@Test
fun pickDirectory_withoutActivity_returnsNoActivityError() {
val plugin = SafUtilPlugin()
val result = mock(MethodChannel.Result::class.java)
plugin.onMethodCall(MethodCall("pickDirectory", null), result)
verify(result).error("NO_ACTIVITY", "Activity is null", null)
verifyNoMoreInteractions(result)
}
@Suppress("DEPRECATION")
@Test
fun pickDirectory_afterConfigChange_reattachesListenerAndClearsPendingResult() {
val plugin = SafUtilPlugin()
val firstActivity = RecordingActivity()
val firstBinding = mock(ActivityPluginBinding::class.java)
`when`(firstBinding.activity).thenReturn(firstActivity)
plugin.onAttachedToActivity(firstBinding)
val firstListenerCaptor = ArgumentCaptor.forClass(PluginRegistry.ActivityResultListener::class.java)
verify(firstBinding).addActivityResultListener(firstListenerCaptor.capture())
val firstResult = mock(MethodChannel.Result::class.java)
plugin.onMethodCall(MethodCall("pickDirectory", null), firstResult)
assertEquals(listOf(1001), firstActivity.startedRequestCodes)
val secondResult = mock(MethodChannel.Result::class.java)
plugin.onMethodCall(MethodCall("pickDirectory", null), secondResult)
verify(secondResult).error("ALREADY_PICKING", "Another picker process is already in progress", null)
plugin.onDetachedFromActivityForConfigChanges()
verify(firstBinding).removeActivityResultListener(firstListenerCaptor.value)
val secondActivity = RecordingActivity()
val secondBinding = mock(ActivityPluginBinding::class.java)
`when`(secondBinding.activity).thenReturn(secondActivity)
plugin.onReattachedToActivityForConfigChanges(secondBinding)
val secondListenerCaptor = ArgumentCaptor.forClass(PluginRegistry.ActivityResultListener::class.java)
verify(secondBinding).addActivityResultListener(secondListenerCaptor.capture())
secondListenerCaptor.value.onActivityResult(1001, Activity.RESULT_CANCELED, null)
verify(firstResult).success(null)
val thirdResult = mock(MethodChannel.Result::class.java)
plugin.onMethodCall(MethodCall("pickDirectory", null), thirdResult)
assertEquals(listOf(1001), secondActivity.startedRequestCodes)
}
private class RecordingActivity : Activity() {
val startedRequestCodes = mutableListOf<Int>()
@Deprecated("Deprecated in Android")
override fun startActivityForResult(intent: Intent?, requestCode: Int) {
startedRequestCodes.add(requestCode)
}
} }
} }
+9 -4
View File
@@ -46,10 +46,12 @@ void main() {
expect(flexibleBool(-1), isFalse); expect(flexibleBool(-1), isFalse);
}); });
test("maps '1' string to true, other strings to false", () { test("maps '1' and true strings to true, other strings to false", () {
expect(flexibleBool('1'), isTrue); expect(flexibleBool('1'), isTrue);
expect(flexibleBool('true'), isTrue);
expect(flexibleBool('TRUE'), isTrue);
expect(flexibleBool('0'), isFalse); expect(flexibleBool('0'), isFalse);
expect(flexibleBool('true'), isFalse); expect(flexibleBool('false'), isFalse);
expect(flexibleBool(''), isFalse); expect(flexibleBool(''), isFalse);
}); });
@@ -72,10 +74,13 @@ void main() {
expect(flexibleBoolNullable(2), isFalse); expect(flexibleBoolNullable(2), isFalse);
}); });
test("maps '1' string to true, other strings to false", () { test("maps '1' and true strings to true, false strings to false", () {
expect(flexibleBoolNullable('1'), isTrue); expect(flexibleBoolNullable('1'), isTrue);
expect(flexibleBoolNullable('true'), isTrue);
expect(flexibleBoolNullable('TRUE'), isTrue);
expect(flexibleBoolNullable('0'), isFalse); expect(flexibleBoolNullable('0'), isFalse);
expect(flexibleBoolNullable('true'), isFalse); expect(flexibleBoolNullable('false'), isFalse);
expect(flexibleBoolNullable('FALSE'), isFalse);
}); });
test('returns null for null and unsupported types', () { test('returns null for null and unsupported types', () {