fix: support mx player variants

close #1304
This commit is contained in:
edde746
2026-06-11 08:35:19 +02:00
parent f214c50c15
commit 38afdab2e4
4 changed files with 71 additions and 31 deletions
@@ -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<String>("filePath")
val packageName = call.argument<String>("package")
val packageNames = call.argument<List<Any?>>("packages")
?.mapNotNull { (it as? String)?.trim()?.takeIf { value -> value.isNotEmpty() } }
?: emptyList()
val title = call.argument<String>("title")?.trim()?.takeIf { it.isNotEmpty() }
val startPositionMs = call.argument<Number>("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<String?>(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)
+34 -6
View File
@@ -83,7 +83,7 @@ Future<bool> _launchWithUrl(String url) {
return launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
Future<bool> _launchAndroidIntent(String url, {required String package}) async {
Future<bool> _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<bool> _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<bool> _launchAndroidIntentCandidates(String url, Iterable<String> packages) async {
for (final package in packages) {
if (await _launchAndroidIntent(url, package: package, fallbackToUrl: false)) return true;
}
return _launchWithUrl(url);
}
Future<bool> _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<bool> _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 = <String, List<String>>{
'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<String> _androidPackageCandidatesForId(String id) {
return _androidPackageMap[id] ?? const [];
}
static List<String> 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 = <ExternalPlayer>[
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',
+2 -16
View File
@@ -131,11 +131,12 @@ class ExternalPlayerService {
MediaItem? metadata,
}) async {
try {
final packages = player.id == 'system_default' ? const <String>[] : KnownPlayers.androidPackageCandidates(player);
final result = await _externalPlayerChannel.invokeMapMethod<String, Object?>('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;
}
}
@@ -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;