import 'dart:io'; import 'package:url_launcher/url_launcher.dart'; import '../utils/app_logger.dart'; typedef PlayerLauncher = Future 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 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 toJson() => { 'id': id, 'name': name, if (isCustom) 'isCustom': true, if (customValue != null) 'customValue': customValue, if (customType != null) 'customType': customType == CustomPlayerType.urlScheme ? 'urlScheme' : 'command', }; @override bool operator ==(Object other) => identical(this, other) || other is ExternalPlayer && runtimeType == other.runtimeType && id == other.id; @override int get hashCode => id.hashCode; } Future _launchWithUrl(String url) { return launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); } Future _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 _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); if (await canLaunchUrl(uri)) { return launchUrl(uri, mode: LaunchMode.externalApplication); } return false; } Future _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 _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 _launchCommandCandidates(Iterable 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 _windowsVlcCommandCandidates(Map environment) { final candidates = ['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 = {}; return [ for (final candidate in candidates) if (seen.add(candidate.toLowerCase())) candidate, ]; } Future _launchWindowsVlc(String url) { return _launchCommandCandidates(_windowsVlcCommandCandidates(Platform.environment), url); } Future _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 = >{ '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( 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 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; } } }