@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user