fix: resolve Jellyfin, logout, playback, and Android regressions
This commit is contained in:
@@ -139,6 +139,56 @@ jobs:
|
||||
echo "No tests found, skipping test execution"
|
||||
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:
|
||||
name: Native Formatting
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -89,7 +89,6 @@ class MainActivity : FlutterActivity() {
|
||||
private val DEVICE_ADJUSTMENT_CHANNEL = "com.plezy/device_adjustment"
|
||||
private val TEXT_INPUT_CHANNEL = "com.plezy/text_input"
|
||||
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 nativeTextInputFocused = false
|
||||
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
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, EXTERNAL_PLAYER_CHANNEL).setMethodCallHandler { call, result ->
|
||||
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) {
|
||||
try {
|
||||
when (method) {
|
||||
|
||||
@@ -84,6 +84,7 @@ interface ExoPlayerDelegate : com.edde746.plezy.shared.PlayerDelegate {
|
||||
uri: String,
|
||||
headers: Map<String, String>?,
|
||||
positionMs: Long,
|
||||
playWhenReady: Boolean,
|
||||
errorMessage: String
|
||||
): Boolean = false
|
||||
}
|
||||
@@ -1110,6 +1111,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
uri = currentMediaUri!!,
|
||||
headers = currentHeaders,
|
||||
positionMs = effectivePosition,
|
||||
playWhenReady = exoPlayer?.playWhenReady ?: true,
|
||||
errorMessage = "Video track present but no decoder available"
|
||||
)
|
||||
return
|
||||
@@ -1217,6 +1219,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
uri = currentMediaUri!!,
|
||||
headers = currentHeaders,
|
||||
positionMs = effectivePosition,
|
||||
playWhenReady = exoPlayer?.playWhenReady ?: true,
|
||||
errorMessage = error.message ?: "Unknown error"
|
||||
) ?: false
|
||||
|
||||
@@ -2650,6 +2653,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
uri = uri,
|
||||
headers = currentHeaders,
|
||||
positionMs = effectivePosition,
|
||||
playWhenReady = player.playWhenReady,
|
||||
errorMessage = "Decoder hang: $decoderName accepted input but produced no output"
|
||||
)
|
||||
}
|
||||
@@ -2700,6 +2704,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
uri = uri,
|
||||
headers = currentHeaders,
|
||||
positionMs = player.currentPosition,
|
||||
playWhenReady = player.playWhenReady,
|
||||
errorMessage = "Video track present but no decoder available"
|
||||
)
|
||||
return
|
||||
@@ -2715,6 +2720,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
uri = uri,
|
||||
headers = currentHeaders,
|
||||
positionMs = player.currentPosition,
|
||||
playWhenReady = player.playWhenReady,
|
||||
errorMessage = "Black screen detected: 0 video frames rendered after ${elapsed}ms"
|
||||
)
|
||||
return
|
||||
@@ -3722,8 +3728,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
|
||||
fun triggerFallback() {
|
||||
val uri = currentMediaUri ?: return
|
||||
val pos = exoPlayer?.currentPosition ?: 0L
|
||||
delegate?.onFormatUnsupported(uri, currentHeaders, pos, "debug: manual fallback trigger")
|
||||
val player = exoPlayer
|
||||
val pos = player?.currentPosition ?: 0L
|
||||
delegate?.onFormatUnsupported(uri, currentHeaders, pos, player?.playWhenReady ?: true, "debug: manual fallback trigger")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
|
||||
@@ -302,9 +302,7 @@ class ExoPlayerPlugin :
|
||||
options.add("sid=no")
|
||||
options.add("secondary-sid=no")
|
||||
appendExternalSubtitleOptions(options, externalSubtitleSnapshot)
|
||||
headers?.forEach { (key, value) ->
|
||||
options.add("http-header-fields-append=$key: $value")
|
||||
}
|
||||
appendHttpHeaderOptions(options, headers)
|
||||
val optionsStr = options.joinToString(",")
|
||||
// Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads)
|
||||
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 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
|
||||
* and observers Dart registered against the ExoPlayer session, then resume
|
||||
@@ -839,7 +847,8 @@ class ExoPlayerPlugin :
|
||||
uri: String,
|
||||
headers: Map<String, String>?,
|
||||
positionMs: Long,
|
||||
externalSubtitles: List<Map<String, Any?>>?
|
||||
externalSubtitles: List<Map<String, Any?>>?,
|
||||
playWhenReady: Boolean
|
||||
) {
|
||||
// Snapshot Dart-registered state on main thread before clearing
|
||||
val pendingProps = pendingMpvProperties.toList()
|
||||
@@ -886,12 +895,11 @@ class ExoPlayerPlugin :
|
||||
val startSeconds = positionMs / 1000.0
|
||||
val options = mutableListOf<String>()
|
||||
options.add(if (positionMs > 0L) "start=$startSeconds" else "start=none")
|
||||
if (!playWhenReady) options.add("pause=yes")
|
||||
options.add("sid=no")
|
||||
options.add("secondary-sid=no")
|
||||
appendExternalSubtitleOptions(options, externalSubtitles)
|
||||
headers?.forEach { (key, value) ->
|
||||
options.add("http-header-fields-append=$key: $value")
|
||||
}
|
||||
appendHttpHeaderOptions(options, headers)
|
||||
val optionsStr = options.joinToString(",")
|
||||
notifyBackendSwitched()
|
||||
core.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr))
|
||||
@@ -919,6 +927,7 @@ class ExoPlayerPlugin :
|
||||
uri: String,
|
||||
headers: Map<String, String>?,
|
||||
positionMs: Long,
|
||||
playWhenReady: Boolean,
|
||||
errorMessage: String
|
||||
): Boolean {
|
||||
if (usingMpvFallback || fallbackInProgress) {
|
||||
@@ -993,7 +1002,7 @@ class ExoPlayerPlugin :
|
||||
usingMpvFallback = true
|
||||
fallbackInProgress = false
|
||||
|
||||
setupMpvFallback(core, act, uri, headers, positionMs, fallbackExternalSubtitles)
|
||||
setupMpvFallback(core, act, uri, headers, positionMs, fallbackExternalSubtitles, playWhenReady)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
fallbackInProgress = false
|
||||
|
||||
@@ -60,7 +60,12 @@ Future<PlexAccountRegistration> registerPlexAccountFromToken({
|
||||
createdAt: 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);
|
||||
|
||||
if (accountUuid.isNotEmpty) {
|
||||
|
||||
@@ -27,6 +27,10 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
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 {
|
||||
if (profileId.isEmpty) return const {};
|
||||
final rows = await (select(downloadOwners)..where((t) => t.profileId.equals(profileId))).get();
|
||||
|
||||
@@ -166,7 +166,7 @@ abstract class LiveTvSupport {
|
||||
|
||||
/// Persist the favorites list (and order, where supported). Plex pushes
|
||||
/// 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.
|
||||
Future<void> setFavoriteChannels(List<FavoriteChannel> channels);
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ class ServerCapabilities {
|
||||
final bool numericUserRating;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Hide an item from Continue Watching without changing watch state or
|
||||
|
||||
@@ -194,6 +194,31 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
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
|
||||
/// connection's public server ids. Physical files stay when any other valid
|
||||
/// owner remains.
|
||||
|
||||
@@ -230,6 +230,7 @@ Future<void> logoutAllProfiles(BuildContext context) async {
|
||||
|
||||
await companionRemote.resetForLogout();
|
||||
await userProfileProvider.logout();
|
||||
await scope.downloads.deleteAllDownloads();
|
||||
scope.multiServer.clearAllConnections();
|
||||
// Drop the profile/connection rows so the next sign-in starts clean and
|
||||
// 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).
|
||||
await scope.database.clearAllWatchActions();
|
||||
await scope.database.clearAllSyncRules();
|
||||
// The API cache is app-global and Plex rows are keyed by server only, so
|
||||
// a later sign-in as a different user must not inherit them.
|
||||
await ApiCache.instance.clearVolatile();
|
||||
// Downloads were removed above, so no pinned cache rows need to survive this
|
||||
// app-global logout into the next sign-in.
|
||||
await ApiCache.instance.clearAll();
|
||||
await scope.hiddenLibraries?.refresh();
|
||||
playbackState.clearShuffle();
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
|
||||
'hiddenForBackground': _hiddenForBackground,
|
||||
'playerSuspendedForTvBackground': _playerSuspendedForTvBackground,
|
||||
'mediaControlsSuspendedForTvBackground': _mediaControlsSuspendedForTvBackground,
|
||||
'pendingForegroundMediaResume': _resumeFromSuspendedMediaControlOnForeground,
|
||||
'backend': _playerBackendLabel,
|
||||
};
|
||||
if (action != null) {
|
||||
@@ -49,7 +48,6 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
|
||||
' hiddenForBackground=$_hiddenForBackground'
|
||||
' playerSuspendedForTvBackground=$_playerSuspendedForTvBackground'
|
||||
' mediaControlsSuspendedForTvBackground=$_mediaControlsSuspendedForTvBackground'
|
||||
' pendingForegroundMediaResume=$_resumeFromSuspendedMediaControlOnForeground'
|
||||
' backend=$_playerBackendLabel',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,52 +24,6 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState {
|
||||
_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;
|
||||
|
||||
@@ -104,8 +58,6 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState {
|
||||
Future<void> _restoreMediaControlsAfterResume() async {
|
||||
if (!_isPlayerInitialized || !mounted) return;
|
||||
|
||||
final resumeRequestedByMediaControl = _consumePendingTvBackgroundMediaControlResume();
|
||||
|
||||
unawaited(_setWakelock(player?.state.isActive ?? false));
|
||||
|
||||
final manager = _mediaControlsManager;
|
||||
@@ -123,16 +75,13 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState {
|
||||
if (!mounted || currentPlayer != player || currentPlayer == null) return;
|
||||
|
||||
final wasPlayingBeforeInactive = _wasPlayingBeforeInactive;
|
||||
if (wasPlayingBeforeInactive || resumeRequestedByMediaControl) {
|
||||
final resumeReason = resumeRequestedByMediaControl
|
||||
? 'TV media control foreground request'
|
||||
: 'returning from inactive state';
|
||||
if (wasPlayingBeforeInactive) {
|
||||
try {
|
||||
await _seekBackForRewind(currentPlayer);
|
||||
await _playWithPlaybackIntent(currentPlayer);
|
||||
appLogger.d('Video resumed after $resumeReason');
|
||||
appLogger.d('Video resumed after returning from inactive state');
|
||||
} 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 {
|
||||
_wasPlayingBeforeInactive = false;
|
||||
}
|
||||
|
||||
@@ -294,13 +294,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
_mediaControlSubscription = mediaControlsManager.controlEvents.listen((event) {
|
||||
final activePlayer = player;
|
||||
if (_mediaControlsSuspendedForTvBackground) {
|
||||
final eventLabel = event.runtimeType.toString();
|
||||
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');
|
||||
}
|
||||
appLogger.d('Media control: ${event.runtimeType} ignored while Android TV background-suspended');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ 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/apple_tv_remote_touch_service.dart';
|
||||
import '../services/media_controls_manager.dart';
|
||||
import '../services/playback_coordinator.dart';
|
||||
@@ -378,7 +377,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
bool _wasPlayingBeforeInactive = false;
|
||||
bool _hiddenForBackground = false;
|
||||
bool _mediaControlsSuspendedForTvBackground = false;
|
||||
bool _resumeFromSuspendedMediaControlOnForeground = false;
|
||||
bool _resumeAfterAppleAudioSessionPause = false;
|
||||
DateTime? _lastPlaybackPauseAt;
|
||||
bool _autoPipEnabled = false;
|
||||
@@ -389,7 +387,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
int _rewindOnResume = 0;
|
||||
Future<void> _lifecycleTransition = Future<void>.value();
|
||||
String _playerBackendLabel = 'unknown';
|
||||
Timer? _tvBackgroundMediaControlResumeTimer;
|
||||
|
||||
/// Android TV: release the native AV pipeline once the app stays
|
||||
/// backgrounded past this grace window. A merely paused player keeps its
|
||||
@@ -1171,7 +1168,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_serverStatusSubscription?.cancel();
|
||||
|
||||
_autoPlayTimer?.cancel();
|
||||
_tvBackgroundMediaControlResumeTimer?.cancel();
|
||||
_tvBackgroundPlayerSuspendTimer?.cancel();
|
||||
|
||||
_stillWatchingTimer?.cancel();
|
||||
|
||||
@@ -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]
|
||||
/// and the live-TV favorite-channel adapter; works on any Jellyfin item.
|
||||
Future<void> _setItemFavorite(String itemId, bool isFavorite) async {
|
||||
final path = '/Users/${_segment(connection.userId)}/FavoriteItems/${_segment(itemId)}';
|
||||
final response = isFavorite ? await _http.post(path) : await _http.delete(path);
|
||||
final path = '/UserFavoriteItems/${_segment(itemId)}';
|
||||
final response = isFavorite
|
||||
? await _http.post(path, queryParameters: {'userId': connection.userId})
|
||||
: await _http.delete(path, queryParameters: {'userId': connection.userId});
|
||||
throwIfHttpError(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,6 +772,11 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
||||
if (player.state.completed) {
|
||||
// Parked at queue end: restart the current track.
|
||||
await player.seek(Duration.zero);
|
||||
final currentTrack = _currentTrack;
|
||||
final currentSource = _currentSource;
|
||||
if (currentTrack != null && currentSource != null) {
|
||||
_bindTrackServices(currentTrack, currentSource);
|
||||
}
|
||||
unawaited(_armNext(_generation));
|
||||
}
|
||||
await player.play();
|
||||
|
||||
@@ -8,22 +8,23 @@ int? flexibleInt(Object? v) => switch (v) {
|
||||
_ => 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.
|
||||
/// Handles Plex API responses where boolean fields may arrive as integers.
|
||||
bool flexibleBool(Object? v) => switch (v) {
|
||||
final bool b => b,
|
||||
final int n => n == 1,
|
||||
final String s => s == '1',
|
||||
final String s => s == '1' || s.toLowerCase() == 'true',
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// Parse a value that may be [bool], [int] (0/1), or [String] ('1') to [bool].
|
||||
/// Returns `null` for `null` or unrecognised values.
|
||||
/// Parse a value that may be [bool], [int] (0/1), or [String] ('1'/'true'/'false') to [bool].
|
||||
/// 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) {
|
||||
final bool b => b,
|
||||
final int n => n == 1,
|
||||
final String s => s == '1',
|
||||
final String s => s == '1' || s.toLowerCase() == 'true',
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ android {
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
isReturnDefaultValues = true
|
||||
all {
|
||||
it.useJUnitPlatform()
|
||||
|
||||
|
||||
+21
-6
@@ -13,6 +13,7 @@ import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.core.net.toUri
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
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.MethodCallHandler
|
||||
import io.flutter.plugin.common.MethodChannel.Result
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import androidx.core.net.toUri
|
||||
|
||||
|
||||
/** SafUtilPlugin */
|
||||
@@ -37,11 +38,15 @@ class SafUtilPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
|
||||
|
||||
private lateinit var context: Context
|
||||
private var activity: Activity? = null
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
|
||||
private var pendingResult: Result? = null
|
||||
private var pendingArguments: PendingArguments? = null
|
||||
private val requestCodeOpenDocumentTree = 1001
|
||||
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
|
||||
/// 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() {
|
||||
activity = null
|
||||
detachFromActivityBinding()
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
attachToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
attachToActivity(binding)
|
||||
}
|
||||
|
||||
private fun attachToActivity(binding: ActivityPluginBinding) {
|
||||
detachFromActivityBinding()
|
||||
activityBinding = binding
|
||||
activity = binding.activity
|
||||
binding.addActivityResultListener { requestCode, resultCode, data ->
|
||||
onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
binding.addActivityResultListener(activityResultListener)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
detachFromActivityBinding()
|
||||
}
|
||||
|
||||
private fun detachFromActivityBinding() {
|
||||
activityBinding?.removeActivityResultListener(activityResultListener)
|
||||
activityBinding = null
|
||||
activity = null
|
||||
}
|
||||
|
||||
|
||||
+76
-14
@@ -1,27 +1,89 @@
|
||||
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.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
|
||||
|
||||
/*
|
||||
* 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 {
|
||||
@Test
|
||||
fun onMethodCall_getPlatformVersion_returnsExpectedValue() {
|
||||
fun onMethodCall_unknownMethod_returnsNotImplemented() {
|
||||
val plugin = SafUtilPlugin()
|
||||
val result = mock(MethodChannel.Result::class.java)
|
||||
|
||||
val call = MethodCall("getPlatformVersion", null)
|
||||
val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java)
|
||||
plugin.onMethodCall(call, mockResult)
|
||||
plugin.onMethodCall(MethodCall("unknown", null), result)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,10 +46,12 @@ void main() {
|
||||
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('true'), isTrue);
|
||||
expect(flexibleBool('TRUE'), isTrue);
|
||||
expect(flexibleBool('0'), isFalse);
|
||||
expect(flexibleBool('true'), isFalse);
|
||||
expect(flexibleBool('false'), isFalse);
|
||||
expect(flexibleBool(''), isFalse);
|
||||
});
|
||||
|
||||
@@ -72,10 +74,13 @@ void main() {
|
||||
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('true'), isTrue);
|
||||
expect(flexibleBoolNullable('TRUE'), isTrue);
|
||||
expect(flexibleBoolNullable('0'), isFalse);
|
||||
expect(flexibleBoolNullable('true'), isFalse);
|
||||
expect(flexibleBoolNullable('false'), isFalse);
|
||||
expect(flexibleBoolNullable('FALSE'), isFalse);
|
||||
});
|
||||
|
||||
test('returns null for null and unsupported types', () {
|
||||
|
||||
Reference in New Issue
Block a user