@@ -4,6 +4,7 @@ import android.app.Activity
|
|||||||
import android.app.ActivityManager
|
import android.app.ActivityManager
|
||||||
import android.app.AppOpsManager
|
import android.app.AppOpsManager
|
||||||
import android.app.PictureInPictureParams
|
import android.app.PictureInPictureParams
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
@@ -482,7 +483,9 @@ class MainActivity : FlutterActivity() {
|
|||||||
when (call.method) {
|
when (call.method) {
|
||||||
"openVideo" -> {
|
"openVideo" -> {
|
||||||
val filePath = call.argument<String>("filePath")
|
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 title = call.argument<String>("title")?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
val startPositionMs = call.argument<Number>("startPositionMs")?.toLong() ?: 0L
|
val startPositionMs = call.argument<Number>("startPositionMs")?.toLong() ?: 0L
|
||||||
|
|
||||||
@@ -516,14 +519,12 @@ class MainActivity : FlutterActivity() {
|
|||||||
grantRead = true
|
grantRead = true
|
||||||
}
|
}
|
||||||
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
fun buildIntent(packageName: String?): Intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
setDataAndType(uri, "video/*")
|
setDataAndType(uri, "video/*")
|
||||||
if (grantRead) {
|
if (grantRead) {
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
}
|
}
|
||||||
if (packageName != null) {
|
packageName?.let { setPackage(it) }
|
||||||
setPackage(packageName)
|
|
||||||
}
|
|
||||||
val startPosition = startPositionMs.coerceAtLeast(0).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
|
val startPosition = startPositionMs.coerceAtLeast(0).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
|
||||||
if (startPosition > 0) {
|
if (startPosition > 0) {
|
||||||
putExtra(API_MX_RESULT_POSITION, startPosition)
|
putExtra(API_MX_RESULT_POSITION, startPosition)
|
||||||
@@ -538,11 +539,25 @@ class MainActivity : FlutterActivity() {
|
|||||||
}
|
}
|
||||||
fileName?.let { putExtra(API_MX_FILENAME, it) }
|
fileName?.let { putExtra(API_MX_FILENAME, it) }
|
||||||
}
|
}
|
||||||
pendingExternalPlayerResult = result
|
|
||||||
startActivityForResult(intent, EXTERNAL_PLAYER_REQUEST_CODE)
|
val targetPackages = if (packageNames.isEmpty()) listOf<String?>(null) else packageNames
|
||||||
} catch (e: android.content.ActivityNotFoundException) {
|
for (packageName in targetPackages) {
|
||||||
|
try {
|
||||||
|
pendingExternalPlayerResult = result
|
||||||
|
startActivityForResult(buildIntent(packageName), EXTERNAL_PLAYER_REQUEST_CODE)
|
||||||
|
return@setMethodCallHandler
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
pendingExternalPlayerResult = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
} catch (e: Exception) {
|
||||||
pendingExternalPlayerResult = null
|
pendingExternalPlayerResult = null
|
||||||
result.error("LAUNCH_FAILED", e.message ?: e.javaClass.simpleName, 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);
|
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(
|
final intentUri = Uri.parse(
|
||||||
'intent:$url#Intent;'
|
'intent:$url#Intent;'
|
||||||
'action=android.intent.action.VIEW;'
|
'action=android.intent.action.VIEW;'
|
||||||
@@ -94,10 +94,17 @@ Future<bool> _launchAndroidIntent(String url, {required String package}) async {
|
|||||||
try {
|
try {
|
||||||
return await launchUrl(intentUri, mode: LaunchMode.externalApplication);
|
return await launchUrl(intentUri, mode: LaunchMode.externalApplication);
|
||||||
} catch (_) {
|
} 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 {
|
Future<bool> _launchUrlScheme(String scheme, String url) async {
|
||||||
final playerUrl = scheme.contains('url=') ? '$scheme${Uri.encodeComponent(url)}' : '$scheme$url';
|
final playerUrl = scheme.contains('url=') ? '$scheme${Uri.encodeComponent(url)}' : '$scheme$url';
|
||||||
final uri = Uri.parse(playerUrl);
|
final uri = Uri.parse(playerUrl);
|
||||||
@@ -192,6 +199,27 @@ Future<bool> _launchCustom(String value, String url, CustomPlayerType type) asyn
|
|||||||
class KnownPlayers {
|
class KnownPlayers {
|
||||||
static final systemDefault = ExternalPlayer(id: 'system_default', name: 'System Default', launch: _launchWithUrl);
|
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>[
|
static final _allPlayers = <ExternalPlayer>[
|
||||||
systemDefault,
|
systemDefault,
|
||||||
ExternalPlayer(
|
ExternalPlayer(
|
||||||
@@ -200,7 +228,7 @@ class KnownPlayers {
|
|||||||
iconAsset: 'assets/player_icons/vlc.svg',
|
iconAsset: 'assets/player_icons/vlc.svg',
|
||||||
isAvailable: Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux || Platform.isWindows,
|
isAvailable: Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux || Platform.isWindows,
|
||||||
launch: (url) {
|
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.isIOS) return _launchUrlScheme('vlc://', url);
|
||||||
if (Platform.isMacOS) return _launchMacApp('VLC', url);
|
if (Platform.isMacOS) return _launchMacApp('VLC', url);
|
||||||
if (Platform.isWindows) return _launchWindowsVlc(url);
|
if (Platform.isWindows) return _launchWindowsVlc(url);
|
||||||
@@ -213,7 +241,7 @@ class KnownPlayers {
|
|||||||
iconAsset: 'assets/player_icons/mpv.svg',
|
iconAsset: 'assets/player_icons/mpv.svg',
|
||||||
isAvailable: Platform.isAndroid || Platform.isMacOS || Platform.isLinux || Platform.isWindows,
|
isAvailable: Platform.isAndroid || Platform.isMacOS || Platform.isLinux || Platform.isWindows,
|
||||||
launch: (url) {
|
launch: (url) {
|
||||||
if (Platform.isAndroid) return _launchAndroidIntent(url, package: 'is.xyz.mpv');
|
if (Platform.isAndroid) return _launchAndroidIntentCandidates(url, _androidPackageCandidatesForId('mpv'));
|
||||||
return _launchCommand('mpv', url);
|
return _launchCommand('mpv', url);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -229,14 +257,14 @@ class KnownPlayers {
|
|||||||
name: 'MX Player',
|
name: 'MX Player',
|
||||||
iconAsset: 'assets/player_icons/mx_player.svg',
|
iconAsset: 'assets/player_icons/mx_player.svg',
|
||||||
isAvailable: Platform.isAndroid,
|
isAvailable: Platform.isAndroid,
|
||||||
launch: (url) => _launchAndroidIntent(url, package: 'com.mxtech.videoplayer.ad'),
|
launch: (url) => _launchAndroidIntentCandidates(url, _androidPackageCandidatesForId('mx_player')),
|
||||||
),
|
),
|
||||||
ExternalPlayer(
|
ExternalPlayer(
|
||||||
id: 'just_player',
|
id: 'just_player',
|
||||||
name: 'Just Player',
|
name: 'Just Player',
|
||||||
iconAsset: 'assets/player_icons/just_player.png',
|
iconAsset: 'assets/player_icons/just_player.png',
|
||||||
isAvailable: Platform.isAndroid,
|
isAvailable: Platform.isAndroid,
|
||||||
launch: (url) => _launchAndroidIntent(url, package: 'com.brouken.player'),
|
launch: (url) => _launchAndroidIntentCandidates(url, _androidPackageCandidatesForId('just_player')),
|
||||||
),
|
),
|
||||||
ExternalPlayer(
|
ExternalPlayer(
|
||||||
id: 'infuse',
|
id: 'infuse',
|
||||||
|
|||||||
@@ -131,11 +131,12 @@ class ExternalPlayerService {
|
|||||||
MediaItem? metadata,
|
MediaItem? metadata,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
|
final packages = player.id == 'system_default' ? const <String>[] : KnownPlayers.androidPackageCandidates(player);
|
||||||
final result = await _externalPlayerChannel.invokeMapMethod<String, Object?>('openVideo', {
|
final result = await _externalPlayerChannel.invokeMapMethod<String, Object?>('openVideo', {
|
||||||
'filePath': url,
|
'filePath': url,
|
||||||
if (metadata?.title?.trim().isNotEmpty == true) 'title': metadata!.title!.trim(),
|
if (metadata?.title?.trim().isNotEmpty == true) 'title': metadata!.title!.trim(),
|
||||||
if ((metadata?.viewOffsetMs ?? 0) > 0) 'startPositionMs': metadata!.viewOffsetMs,
|
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);
|
return _ExternalPlayerLaunchResult.fromMap(result);
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
@@ -265,19 +266,4 @@ class ExternalPlayerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static int? _positive(int? value) => value != null && value > 0 ? value : null;
|
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_kind.dart';
|
||||||
import 'package:plezy/media/media_server_client.dart';
|
import 'package:plezy/media/media_server_client.dart';
|
||||||
import 'package:plezy/media/playback_report_metadata.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/external_player_service.dart';
|
||||||
import 'package:plezy/services/jellyfin_api_cache.dart';
|
import 'package:plezy/services/jellyfin_api_cache.dart';
|
||||||
import 'package:plezy/services/multi_server_manager.dart';
|
import 'package:plezy/services/multi_server_manager.dart';
|
||||||
@@ -84,6 +85,16 @@ MediaItem _item({int? durationMs}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
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 {
|
test('Android external progress preserves null duration and still stops after start failure', () async {
|
||||||
final client = _RecordingClient()..failStart = true;
|
final client = _RecordingClient()..failStart = true;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user