fix(settings): list external players only when they are installed
Detection now runs off the UI isolate and covers every platform where the answer can be trusted. Availability was a plain platform check, so Linux always listed VLC, mpv and Celluloid, macOS always listed VLC and IINA, and Windows always listed VLC and PotPlayer whether or not any of them existed. Each player now has a detector that asks exactly the question its launcher asks: `sh -c 'command -v'` for PATH launches so the kernel performs the executable check, NSWorkspace/Launch Services for `open -a`, `where.exe` plus the concrete install paths for Windows VLC, and the registered URL handler for PotPlayer and the iOS players. Detection is asynchronous and memoised behind KnownPlayers.probe rather than a Process.runSync in a static initialiser, which forked three shells on the UI isolate during ExternalPlayerScreen.build. It is prewarmed from startup, fails open when a probe throws, and keeps the selected player listed when a detector misses it so a false negative cannot leave the list with nothing selected. iOS and tvOS gained LSApplicationQueriesSchemes entries for vlc and infuse. Without them canOpenURL returns false for both schemes, so _launchUrlScheme was already refusing to hand off to either player. Android keeps the platform check: package visibility needs native declarations, and a wrong answer there hides a working player.
This commit is contained in:
@@ -25,6 +25,7 @@ import 'profiles/profile_connection_cleanup.dart';
|
||||
import 'profiles/profile_connection_registry.dart';
|
||||
import 'profiles/profile_registry.dart';
|
||||
import 'profiles/profile_selection_policy.dart';
|
||||
import 'models/external_player_models.dart';
|
||||
import 'mixins/mounted_set_state_mixin.dart';
|
||||
import 'theme/mono_theme.dart';
|
||||
import 'profiles/plex_home_service.dart';
|
||||
@@ -487,6 +488,9 @@ void _startNonessentialInitialization(SettingsService settings) {
|
||||
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
bestEffort('Discord RPC', DiscordRPCService.instance.initialize);
|
||||
// Detection forks helper processes; resolve it here so the External Player
|
||||
// settings page never has to wait on a cold probe.
|
||||
bestEffort('External player detection', KnownPlayers.getForCurrentPlatform);
|
||||
}
|
||||
|
||||
if (settings.read(SettingsService.crashReporting)) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../utils/app_logger.dart';
|
||||
@@ -8,6 +10,31 @@ typedef PlayerLauncher = Future<bool> Function(String url);
|
||||
|
||||
enum CustomPlayerType { command, urlScheme }
|
||||
|
||||
/// Host lookups behind [KnownPlayers.getForCurrentPlatform], split out so tests
|
||||
/// can answer them without touching the real machine.
|
||||
class PlayerInstallProbe {
|
||||
const PlayerInstallProbe();
|
||||
|
||||
/// Drives which detectors run. Values match [Platform.operatingSystem].
|
||||
String get operatingSystem => Platform.operatingSystem;
|
||||
|
||||
Map<String, String> get environment => Platform.environment;
|
||||
|
||||
Future<ProcessResult> run(String executable, List<String> arguments) => Process.run(executable, arguments);
|
||||
|
||||
Future<bool> fileExists(String path) => File(path).exists();
|
||||
|
||||
/// macOS Launch Services lookup — the database `open -a` resolves through.
|
||||
/// Spotlight is deliberately not consulted: an empty `mdfind` result also
|
||||
/// means indexing is off or incomplete, which would hide installed players.
|
||||
Future<bool> applicationInstalled(String bundleId) async {
|
||||
const channel = MethodChannel('com.plezy/app_lookup');
|
||||
return await channel.invokeMethod<bool>('isApplicationInstalled', {'bundleId': bundleId}) ?? false;
|
||||
}
|
||||
|
||||
Future<bool> schemeHasHandler(String scheme) => canLaunchUrl(Uri.parse(scheme));
|
||||
}
|
||||
|
||||
class ExternalPlayer {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -204,19 +231,6 @@ class KnownPlayers {
|
||||
return _androidPackageMap[id] ?? const [];
|
||||
}
|
||||
|
||||
static bool _isLinuxCommandAvailable(String command) {
|
||||
if (!Platform.isLinux) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
final result = Process.runSync('sh', ['-c', r'command -v "$1" >/dev/null 2>&1', 'plezy-command-probe', command]);
|
||||
return result.exitCode == 0;
|
||||
} on ProcessException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> androidPackageCandidates(ExternalPlayer player) {
|
||||
final knownPackages = _androidPackageCandidatesForId(player.id);
|
||||
if (knownPackages.isNotEmpty) return knownPackages;
|
||||
@@ -233,12 +247,7 @@ class KnownPlayers {
|
||||
id: 'vlc',
|
||||
name: 'VLC',
|
||||
iconAsset: 'assets/player_icons/vlc.svg',
|
||||
isAvailable:
|
||||
Platform.isAndroid ||
|
||||
Platform.isIOS ||
|
||||
Platform.isMacOS ||
|
||||
_isLinuxCommandAvailable('vlc') ||
|
||||
Platform.isWindows,
|
||||
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);
|
||||
@@ -251,7 +260,7 @@ class KnownPlayers {
|
||||
id: 'mpv',
|
||||
name: 'mpv',
|
||||
iconAsset: 'assets/player_icons/mpv.svg',
|
||||
isAvailable: Platform.isAndroid || Platform.isMacOS || _isLinuxCommandAvailable('mpv') || Platform.isWindows,
|
||||
isAvailable: Platform.isAndroid || Platform.isMacOS || Platform.isLinux || Platform.isWindows,
|
||||
launch: (url) {
|
||||
if (Platform.isAndroid) return _launchAndroidIntentCandidates(url, _androidPackageCandidatesForId('mpv'));
|
||||
return _launchCommand('mpv', url);
|
||||
@@ -299,14 +308,121 @@ class KnownPlayers {
|
||||
id: 'celluloid',
|
||||
name: 'Celluloid',
|
||||
iconAsset: 'assets/player_icons/celluloid.svg',
|
||||
isAvailable: _isLinuxCommandAvailable('celluloid'),
|
||||
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();
|
||||
/// Host lookups used to decide whether a supported player is actually
|
||||
/// installed. Overridden in tests; production talks to the real process,
|
||||
/// filesystem and URL-handler APIs.
|
||||
@visibleForTesting
|
||||
static PlayerInstallProbe probe = const PlayerInstallProbe();
|
||||
|
||||
static Future<List<ExternalPlayer>>? _installedPlayers;
|
||||
|
||||
/// Players supported on this platform, minus the ones we can positively tell
|
||||
/// are not installed.
|
||||
///
|
||||
/// Detection forks `sh` or `where.exe` and calls out over platform channels,
|
||||
/// so it is asynchronous and memoised: it runs once per process and a player
|
||||
/// installed while Plezy is running only appears after a restart. Android is
|
||||
/// the one platform left unprobed — where there is no reliable check the
|
||||
/// player stays listed, because hiding a working player is worse than
|
||||
/// listing a missing one.
|
||||
static Future<List<ExternalPlayer>> getForCurrentPlatform() {
|
||||
return _installedPlayers ??= _resolveForCurrentPlatform();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static void resetForTesting() {
|
||||
probe = const PlayerInstallProbe();
|
||||
_installedPlayers = null;
|
||||
}
|
||||
|
||||
static Future<List<ExternalPlayer>> _resolveForCurrentPlatform() async {
|
||||
final supported = [
|
||||
for (final player in _allPlayers)
|
||||
if (player.isAvailable) player,
|
||||
];
|
||||
|
||||
// Each detector must answer the same question its launcher asks, so a
|
||||
// listed player is one we can actually start.
|
||||
final detectors = <String, Future<bool> Function()>{};
|
||||
switch (probe.operatingSystem) {
|
||||
case 'linux':
|
||||
detectors['vlc'] = () => _posixCommandExists('vlc');
|
||||
detectors['mpv'] = () => _posixCommandExists('mpv');
|
||||
detectors['celluloid'] = () => _posixCommandExists('celluloid');
|
||||
case 'macos':
|
||||
detectors['vlc'] = () => probe.applicationInstalled('org.videolan.vlc');
|
||||
detectors['iina'] = () => probe.applicationInstalled('com.colliderli.iina');
|
||||
detectors['mpv'] = () => _posixCommandExists('mpv');
|
||||
case 'windows':
|
||||
detectors['vlc'] = _windowsVlcExists;
|
||||
detectors['mpv'] = () => _windowsCommandExists('mpv');
|
||||
detectors['potplayer'] = _windowsPotPlayerExists;
|
||||
case 'ios':
|
||||
// Same predicate _launchUrlScheme gates on, so detection can never
|
||||
// hide a player the handoff would have reached. Both schemes are
|
||||
// declared in LSApplicationQueriesSchemes.
|
||||
detectors['vlc'] = () => probe.schemeHasHandler('vlc://');
|
||||
detectors['infuse'] = () => probe.schemeHasHandler('infuse://');
|
||||
}
|
||||
if (detectors.isEmpty) return supported;
|
||||
|
||||
final ids = detectors.keys.toList(growable: false);
|
||||
final found = await Future.wait(ids.map((id) => _detect(id, detectors[id]!)));
|
||||
final missing = <String>{
|
||||
for (var i = 0; i < ids.length; i++)
|
||||
if (!found[i]) ids[i],
|
||||
};
|
||||
if (missing.isEmpty) return supported;
|
||||
|
||||
return [
|
||||
for (final player in supported)
|
||||
if (!missing.contains(player.id)) player,
|
||||
];
|
||||
}
|
||||
|
||||
/// Runs one detector, failing open: a probe that blows up must not hide a
|
||||
/// player the user may well have installed.
|
||||
static Future<bool> _detect(String id, Future<bool> Function() detector) async {
|
||||
try {
|
||||
return await detector();
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.w('External player detection failed for $id; listing it anyway', error: e, stackTrace: stackTrace);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// True when [command] resolves on `PATH`. The shell defers to the kernel, so
|
||||
/// permission classes, ACLs and `noexec` mounts are honoured exactly as they
|
||||
/// will be when [Process.start] execs the command.
|
||||
static Future<bool> _posixCommandExists(String command) async {
|
||||
final result = await probe.run('sh', ['-c', r'command -v "$1" >/dev/null 2>&1', 'plezy-command-probe', command]);
|
||||
return result.exitCode == 0;
|
||||
}
|
||||
|
||||
static Future<bool> _windowsCommandExists(String command) async {
|
||||
final result = await probe.run('where.exe', [command]);
|
||||
return result.exitCode == 0;
|
||||
}
|
||||
|
||||
/// Mirrors [_launchWindowsVlc]: any of the concrete install paths, or `vlc`
|
||||
/// on `PATH`.
|
||||
static Future<bool> _windowsVlcExists() async {
|
||||
for (final candidate in _windowsVlcCommandCandidates(probe.environment)) {
|
||||
if (candidate.contains(r'\') && await probe.fileExists(candidate)) return true;
|
||||
}
|
||||
return _windowsCommandExists('vlc');
|
||||
}
|
||||
|
||||
/// Mirrors the PotPlayer launcher: the registered `potplayer://` handler, or
|
||||
/// the executable on `PATH`.
|
||||
static Future<bool> _windowsPotPlayerExists() async {
|
||||
if (await probe.schemeHasHandler('potplayer://')) return true;
|
||||
return _windowsCommandExists('PotPlayerMini64');
|
||||
}
|
||||
|
||||
/// Find a known player by ID
|
||||
|
||||
@@ -24,7 +24,6 @@ class ExternalPlayerScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final knownPlayers = KnownPlayers.getForCurrentPlatform();
|
||||
return SettingsPage(
|
||||
title: Text(t.externalPlayer.title),
|
||||
children: [
|
||||
@@ -51,9 +50,19 @@ class ExternalPlayerScreen extends StatelessWidget {
|
||||
final custom = svc.read(SettingsService.customExternalPlayers);
|
||||
return Column(
|
||||
children: [
|
||||
SettingsGroup(
|
||||
title: t.externalPlayer.selectPlayer,
|
||||
children: [for (final p in knownPlayers) _PlayerTile(player: p, selectedId: selected.id)],
|
||||
FutureBuilder<List<ExternalPlayer>>(
|
||||
future: KnownPlayers.getForCurrentPlatform(),
|
||||
builder: (context, snapshot) {
|
||||
final detected = snapshot.data;
|
||||
if (detected == null) return const SizedBox.shrink();
|
||||
return SettingsGroup(
|
||||
title: t.externalPlayer.selectPlayer,
|
||||
children: [
|
||||
for (final p in _withSelected(detected, selected.id))
|
||||
_PlayerTile(player: p, selectedId: selected.id),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
SettingsGroup(
|
||||
title: t.externalPlayer.customPlayers,
|
||||
@@ -76,6 +85,15 @@ class ExternalPlayerScreen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the current choice on screen when detection missed it, so a false
|
||||
/// negative cannot leave the list with nothing selected.
|
||||
List<ExternalPlayer> _withSelected(List<ExternalPlayer> detected, String selectedId) {
|
||||
if (detected.any((p) => p.id == selectedId)) return detected;
|
||||
final selected = KnownPlayers.findById(selectedId);
|
||||
if (selected == null || !selected.isAvailable) return detected;
|
||||
return [...detected, selected];
|
||||
}
|
||||
|
||||
class _PlayerTile extends StatelessWidget {
|
||||
final ExternalPlayer player;
|
||||
final String selectedId;
|
||||
|
||||
Reference in New Issue
Block a user