Files
plezy/lib/models/external_player_models.dart
T
2026-06-11 08:35:19 +02:00

309 lines
9.7 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:url_launcher/url_launcher.dart';
import '../utils/app_logger.dart';
typedef PlayerLauncher = Future<bool> Function(String url);
enum CustomPlayerType { command, urlScheme }
class ExternalPlayer {
final String id;
final String name;
final String? iconAsset;
final bool isAvailable;
final PlayerLauncher launch;
final bool isCustom;
final String? customValue; // Only for custom player serialization
final CustomPlayerType? customType;
ExternalPlayer({
required this.id,
required this.name,
this.iconAsset,
this.isAvailable = true,
required this.launch,
this.isCustom = false,
this.customValue,
this.customType,
});
factory ExternalPlayer.custom({
required String id,
required String name,
required String value,
required CustomPlayerType type,
}) {
return ExternalPlayer(
id: id,
name: name,
isCustom: true,
customValue: value,
customType: type,
launch: (url) => _launchCustom(value, url, type),
);
}
factory ExternalPlayer.fromJson(Map<String, dynamic> json) {
final id = json['id'] as String;
final known = KnownPlayers.findById(id);
if (known != null) return known;
return ExternalPlayer.custom(
id: id,
name: json['name'] as String,
value: json['customValue'] as String? ?? '',
type: json['customType'] == 'urlScheme' ? CustomPlayerType.urlScheme : CustomPlayerType.command,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
if (isCustom) 'isCustom': true,
if (customValue != null) 'customValue': customValue,
if (customType != null) 'customType': customType == CustomPlayerType.urlScheme ? 'urlScheme' : 'command',
};
String toJsonString() => json.encode(toJson());
static ExternalPlayer fromJsonString(String jsonString) =>
ExternalPlayer.fromJson(json.decode(jsonString) as Map<String, dynamic>);
@override
bool operator ==(Object other) =>
identical(this, other) || other is ExternalPlayer && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}
Future<bool> _launchWithUrl(String url) {
return launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
Future<bool> _launchAndroidIntent(String url, {required String package, bool fallbackToUrl = true}) async {
final intentUri = Uri.parse(
'intent:$url#Intent;'
'action=android.intent.action.VIEW;'
'type=video/*;'
'package=$package;'
'end',
);
try {
return await launchUrl(intentUri, mode: LaunchMode.externalApplication);
} catch (_) {
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);
if (await canLaunchUrl(uri)) {
return launchUrl(uri, mode: LaunchMode.externalApplication);
}
return false;
}
Future<bool> _launchMacApp(String appName, String url) async {
try {
await Process.start('open', ['-a', appName, url], mode: ProcessStartMode.detached);
return true;
} catch (e) {
appLogger.w('Failed to launch $appName via open -a', error: e);
return false;
}
}
Future<bool> _launchCommand(String command, String url) async {
try {
final result = await Process.start(command, [url], mode: ProcessStartMode.detached);
appLogger.d('Launched $command with PID: ${result.pid}');
return true;
} catch (e) {
appLogger.w('Failed to launch $command', error: e);
return false;
}
}
Future<bool> _launchCommandCandidates(Iterable<String> commands, String url) async {
final commandList = commands.toList(growable: false);
Object? lastError;
for (final command in commandList) {
try {
final result = await Process.start(command, [url], mode: ProcessStartMode.detached);
appLogger.d('Launched $command with PID: ${result.pid}');
return true;
} catch (e) {
lastError = e;
appLogger.d('Failed to launch $command', error: e);
}
}
appLogger.w('Failed to launch any of: ${commandList.join(', ')}', error: lastError);
return false;
}
List<String> _windowsVlcCommandCandidates(Map<String, String> environment) {
final candidates = <String>['vlc'];
void addInstallPath(String? programFilesDir) {
final trimmed = programFilesDir?.trim();
if (trimmed == null || trimmed.isEmpty) return;
final root = trimmed.replaceFirst(RegExp(r'[\\/]+$'), '');
candidates.add('$root\\VideoLAN\\VLC\\vlc.exe');
}
addInstallPath(environment['ProgramW6432']);
addInstallPath(environment['ProgramFiles']);
addInstallPath(environment['ProgramFiles(x86)']);
addInstallPath(r'C:\Program Files');
addInstallPath(r'C:\Program Files (x86)');
final seen = <String>{};
return [
for (final candidate in candidates)
if (seen.add(candidate.toLowerCase())) candidate,
];
}
Future<bool> _launchWindowsVlc(String url) {
return _launchCommandCandidates(_windowsVlcCommandCandidates(Platform.environment), url);
}
Future<bool> _launchCustom(String value, String url, CustomPlayerType type) async {
if (type == CustomPlayerType.urlScheme) {
return _launchUrlScheme(value, url);
}
// Command type
if (Platform.isAndroid) {
return _launchAndroidIntent(url, package: value);
} else if (Platform.isMacOS) {
// Try PATH first (e.g. mpv), fall back to open -a (e.g. VLC)
if (await _launchCommand(value, url)) return true;
return _launchMacApp(value, url);
} else {
return _launchCommand(value, url);
}
}
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(
id: 'vlc',
name: 'VLC',
iconAsset: 'assets/player_icons/vlc.svg',
isAvailable: Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux || Platform.isWindows,
launch: (url) {
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);
return _launchCommand('vlc', url);
},
),
ExternalPlayer(
id: 'mpv',
name: 'mpv',
iconAsset: 'assets/player_icons/mpv.svg',
isAvailable: Platform.isAndroid || Platform.isMacOS || Platform.isLinux || Platform.isWindows,
launch: (url) {
if (Platform.isAndroid) return _launchAndroidIntentCandidates(url, _androidPackageCandidatesForId('mpv'));
return _launchCommand('mpv', url);
},
),
ExternalPlayer(
id: 'iina',
name: 'IINA',
iconAsset: 'assets/player_icons/iina.png',
isAvailable: Platform.isMacOS,
launch: (url) => _launchUrlScheme('iina://weblink?url=', url),
),
ExternalPlayer(
id: 'mx_player',
name: 'MX Player',
iconAsset: 'assets/player_icons/mx_player.svg',
isAvailable: Platform.isAndroid,
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) => _launchAndroidIntentCandidates(url, _androidPackageCandidatesForId('just_player')),
),
ExternalPlayer(
id: 'infuse',
name: 'Infuse',
iconAsset: 'assets/player_icons/infuse.png',
isAvailable: Platform.isIOS,
launch: (url) => _launchUrlScheme('infuse://x-callback-url/play?url=', url),
),
ExternalPlayer(
id: 'potplayer',
name: 'PotPlayer',
iconAsset: 'assets/player_icons/potplayer.png',
isAvailable: Platform.isWindows,
launch: (url) async {
if (await _launchUrlScheme('potplayer://', url)) return true;
return _launchCommand('PotPlayerMini64', url);
},
),
ExternalPlayer(
id: 'celluloid',
name: 'Celluloid',
iconAsset: 'assets/player_icons/celluloid.svg',
isAvailable: Platform.isLinux,
launch: (url) => _launchCommand('celluloid', url),
),
];
/// Get players available on the current platform
static List<ExternalPlayer> getForCurrentPlatform() {
return _allPlayers.where((p) => p.isAvailable).toList();
}
/// Find a known player by ID
static ExternalPlayer? findById(String id) {
try {
return _allPlayers.firstWhere((p) => p.id == id);
} catch (_) {
return null;
}
}
}