From 38afdab2e408ae4c7fcc3ec347e0cad1f9a76728 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:35:19 +0200 Subject: [PATCH] fix: support mx player variants close #1304 --- .../kotlin/com/edde746/plezy/MainActivity.kt | 33 ++++++++++----- lib/models/external_player_models.dart | 40 ++++++++++++++++--- lib/services/external_player_service.dart | 18 +-------- .../external_player_service_test.dart | 11 +++++ 4 files changed, 71 insertions(+), 31 deletions(-) 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 6957ad8c..ee6b456d 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.app.ActivityManager import android.app.AppOpsManager import android.app.PictureInPictureParams +import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.pm.PackageManager @@ -482,7 +483,9 @@ class MainActivity : FlutterActivity() { when (call.method) { "openVideo" -> { val filePath = call.argument("filePath") - val packageName = call.argument("package") + val packageNames = call.argument>("packages") + ?.mapNotNull { (it as? String)?.trim()?.takeIf { value -> value.isNotEmpty() } } + ?: emptyList() val title = call.argument("title")?.trim()?.takeIf { it.isNotEmpty() } val startPositionMs = call.argument("startPositionMs")?.toLong() ?: 0L @@ -516,14 +519,12 @@ class MainActivity : FlutterActivity() { grantRead = true } - val intent = Intent(Intent.ACTION_VIEW).apply { + fun buildIntent(packageName: String?): Intent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, "video/*") if (grantRead) { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - if (packageName != null) { - setPackage(packageName) - } + packageName?.let { setPackage(it) } val startPosition = startPositionMs.coerceAtLeast(0).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() if (startPosition > 0) { putExtra(API_MX_RESULT_POSITION, startPosition) @@ -538,11 +539,25 @@ class MainActivity : FlutterActivity() { } fileName?.let { putExtra(API_MX_FILENAME, it) } } - pendingExternalPlayerResult = result - startActivityForResult(intent, EXTERNAL_PLAYER_REQUEST_CODE) - } catch (e: android.content.ActivityNotFoundException) { + + val targetPackages = if (packageNames.isEmpty()) listOf(null) else packageNames + for (packageName in targetPackages) { + try { + pendingExternalPlayerResult = result + startActivityForResult(buildIntent(packageName), EXTERNAL_PLAYER_REQUEST_CODE) + return@setMethodCallHandler + } catch (e: ActivityNotFoundException) { + pendingExternalPlayerResult = null + } + } + pendingExternalPlayerResult = null - result.error("APP_NOT_FOUND", "No app found for package: $packageName", null) + val message = if (packageNames.isEmpty()) { + "No app found for video" + } else { + "No app found for packages: ${packageNames.joinToString(", ")}" + } + result.error("APP_NOT_FOUND", message, null) } catch (e: Exception) { pendingExternalPlayerResult = null result.error("LAUNCH_FAILED", e.message ?: e.javaClass.simpleName, null) diff --git a/lib/models/external_player_models.dart b/lib/models/external_player_models.dart index a13dafd0..e439ab34 100644 --- a/lib/models/external_player_models.dart +++ b/lib/models/external_player_models.dart @@ -83,7 +83,7 @@ Future _launchWithUrl(String url) { return launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); } -Future _launchAndroidIntent(String url, {required String package}) async { +Future _launchAndroidIntent(String url, {required String package, bool fallbackToUrl = true}) async { final intentUri = Uri.parse( 'intent:$url#Intent;' 'action=android.intent.action.VIEW;' @@ -94,10 +94,17 @@ Future _launchAndroidIntent(String url, {required String package}) async { try { return await launchUrl(intentUri, mode: LaunchMode.externalApplication); } catch (_) { - return _launchWithUrl(url); + return fallbackToUrl ? _launchWithUrl(url) : false; } } +Future _launchAndroidIntentCandidates(String url, Iterable packages) async { + for (final package in packages) { + if (await _launchAndroidIntent(url, package: package, fallbackToUrl: false)) return true; + } + return _launchWithUrl(url); +} + Future _launchUrlScheme(String scheme, String url) async { final playerUrl = scheme.contains('url=') ? '$scheme${Uri.encodeComponent(url)}' : '$scheme$url'; final uri = Uri.parse(playerUrl); @@ -192,6 +199,27 @@ Future _launchCustom(String value, String url, CustomPlayerType type) asyn class KnownPlayers { static final systemDefault = ExternalPlayer(id: 'system_default', name: 'System Default', launch: _launchWithUrl); + static const _androidPackageMap = >{ + 'vlc': ['org.videolan.vlc'], + 'mpv': ['is.xyz.mpv'], + 'mx_player': ['com.mxtech.videoplayer.ad', 'com.mxtech.videoplayer.pro'], + 'just_player': ['com.brouken.player'], + }; + + static List _androidPackageCandidatesForId(String id) { + return _androidPackageMap[id] ?? const []; + } + + static List androidPackageCandidates(ExternalPlayer player) { + final knownPackages = _androidPackageCandidatesForId(player.id); + if (knownPackages.isNotEmpty) return knownPackages; + if (player.isCustom && player.customType == CustomPlayerType.command) { + final package = player.customValue?.trim(); + return package == null || package.isEmpty ? const [] : [package]; + } + return const []; + } + static final _allPlayers = [ systemDefault, ExternalPlayer( @@ -200,7 +228,7 @@ class KnownPlayers { iconAsset: 'assets/player_icons/vlc.svg', isAvailable: Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux || Platform.isWindows, launch: (url) { - if (Platform.isAndroid) return _launchAndroidIntent(url, package: 'org.videolan.vlc'); + if (Platform.isAndroid) return _launchAndroidIntentCandidates(url, _androidPackageCandidatesForId('vlc')); if (Platform.isIOS) return _launchUrlScheme('vlc://', url); if (Platform.isMacOS) return _launchMacApp('VLC', url); if (Platform.isWindows) return _launchWindowsVlc(url); @@ -213,7 +241,7 @@ class KnownPlayers { iconAsset: 'assets/player_icons/mpv.svg', isAvailable: Platform.isAndroid || Platform.isMacOS || Platform.isLinux || Platform.isWindows, launch: (url) { - if (Platform.isAndroid) return _launchAndroidIntent(url, package: 'is.xyz.mpv'); + if (Platform.isAndroid) return _launchAndroidIntentCandidates(url, _androidPackageCandidatesForId('mpv')); return _launchCommand('mpv', url); }, ), @@ -229,14 +257,14 @@ class KnownPlayers { name: 'MX Player', iconAsset: 'assets/player_icons/mx_player.svg', isAvailable: Platform.isAndroid, - launch: (url) => _launchAndroidIntent(url, package: 'com.mxtech.videoplayer.ad'), + launch: (url) => _launchAndroidIntentCandidates(url, _androidPackageCandidatesForId('mx_player')), ), ExternalPlayer( id: 'just_player', name: 'Just Player', iconAsset: 'assets/player_icons/just_player.png', isAvailable: Platform.isAndroid, - launch: (url) => _launchAndroidIntent(url, package: 'com.brouken.player'), + launch: (url) => _launchAndroidIntentCandidates(url, _androidPackageCandidatesForId('just_player')), ), ExternalPlayer( id: 'infuse', diff --git a/lib/services/external_player_service.dart b/lib/services/external_player_service.dart index 80980925..7cfaabaf 100644 --- a/lib/services/external_player_service.dart +++ b/lib/services/external_player_service.dart @@ -131,11 +131,12 @@ class ExternalPlayerService { MediaItem? metadata, }) async { try { + final packages = player.id == 'system_default' ? const [] : KnownPlayers.androidPackageCandidates(player); final result = await _externalPlayerChannel.invokeMapMethod('openVideo', { 'filePath': url, if (metadata?.title?.trim().isNotEmpty == true) 'title': metadata!.title!.trim(), if ((metadata?.viewOffsetMs ?? 0) > 0) 'startPositionMs': metadata!.viewOffsetMs, - if (player.id != 'system_default') 'package': _getAndroidPackage(player), + if (packages.isNotEmpty) 'packages': packages, }); return _ExternalPlayerLaunchResult.fromMap(result); } on PlatformException catch (e) { @@ -265,19 +266,4 @@ class ExternalPlayerService { } static int? _positive(int? value) => value != null && value > 0 ? value : null; - - /// Map known player IDs to their Android package names. - static String? _getAndroidPackage(ExternalPlayer player) { - const packageMap = { - 'vlc': 'org.videolan.vlc', - 'mpv': 'is.xyz.mpv', - 'mx_player': 'com.mxtech.videoplayer.ad', - 'just_player': 'com.brouken.player', - }; - // Known players - if (packageMap.containsKey(player.id)) return packageMap[player.id]; - // Custom command-type players use the value as package name on Android - if (player.isCustom && player.customType == CustomPlayerType.command) return player.customValue; - return null; - } } diff --git a/test/services/external_player_service_test.dart b/test/services/external_player_service_test.dart index f1fad645..77003d23 100644 --- a/test/services/external_player_service_test.dart +++ b/test/services/external_player_service_test.dart @@ -7,6 +7,7 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/playback_report_metadata.dart'; +import 'package:plezy/models/external_player_models.dart'; import 'package:plezy/services/external_player_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/multi_server_manager.dart'; @@ -84,6 +85,16 @@ MediaItem _item({int? durationMs}) { } void main() { + test('MX Player Android package candidates include free and Pro variants', () { + final mxPlayer = KnownPlayers.findById('mx_player'); + + expect(mxPlayer, isNotNull); + expect(KnownPlayers.androidPackageCandidates(mxPlayer!), [ + 'com.mxtech.videoplayer.ad', + 'com.mxtech.videoplayer.pro', + ]); + }); + test('Android external progress preserves null duration and still stops after start failure', () async { final client = _RecordingClient()..failStart = true;