diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 38d3b9ca..7848e2b0 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -26,6 +26,13 @@
$(FLUTTER_BUILD_NUMBER)
ITSAppUsesNonExemptEncryption
+
+ LSApplicationQueriesSchemes
+
+ vlc
+ infuse
+
LSRequiresIPhoneOS
LSSupportsOpeningDocumentsInPlace
diff --git a/lib/main.dart b/lib/main.dart
index a961c4bf..4ecc1a78 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -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)) {
diff --git a/lib/models/external_player_models.dart b/lib/models/external_player_models.dart
index 3f29325f..df724085 100644
--- a/lib/models/external_player_models.dart
+++ b/lib/models/external_player_models.dart
@@ -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 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 get environment => Platform.environment;
+
+ Future run(String executable, List arguments) => Process.run(executable, arguments);
+
+ Future 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 applicationInstalled(String bundleId) async {
+ const channel = MethodChannel('com.plezy/app_lookup');
+ return await channel.invokeMethod('isApplicationInstalled', {'bundleId': bundleId}) ?? false;
+ }
+
+ Future 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 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 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>? _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> getForCurrentPlatform() {
+ return _installedPlayers ??= _resolveForCurrentPlatform();
+ }
+
+ @visibleForTesting
+ static void resetForTesting() {
+ probe = const PlayerInstallProbe();
+ _installedPlayers = null;
+ }
+
+ static Future> _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 = 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 = {
+ 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 _detect(String id, Future 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 _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 _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 _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 _windowsPotPlayerExists() async {
+ if (await probe.schemeHasHandler('potplayer://')) return true;
+ return _windowsCommandExists('PotPlayerMini64');
}
/// Find a known player by ID
diff --git a/lib/screens/settings/external_player_screen.dart b/lib/screens/settings/external_player_screen.dart
index c6b0c2e4..355c792c 100644
--- a/lib/screens/settings/external_player_screen.dart
+++ b/lib/screens/settings/external_player_screen.dart
@@ -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>(
+ 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 _withSelected(List 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;
diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj
index 3e02c9e0..b0b8580c 100644
--- a/macos/Runner.xcodeproj/project.pbxproj
+++ b/macos/Runner.xcodeproj/project.pbxproj
@@ -33,6 +33,7 @@
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */; };
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */; };
6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */; };
+ 6AD8B1702ED7B50000E9E1C1 /* AppLookupPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1712ED7B50000E9E1C1 /* AppLookupPlugin.swift */; };
6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */; };
6AD8B16A2ED7B50000E9E1B6 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */; };
B1D51A6A2F00110000000003 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */; };
@@ -94,6 +95,7 @@
6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = ""; };
6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = ""; };
6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowUtilsPlugin.swift; sourceTree = ""; };
+ 6AD8B1712ED7B50000E9E1C1 /* AppLookupPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLookupPlugin.swift; sourceTree = ""; };
6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDelegate.swift; sourceTree = ""; };
6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = ""; };
6AD8B16C2ED7B50000E9E1B7 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
@@ -216,6 +218,7 @@
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */,
+ 6AD8B1712ED7B50000E9E1C1 /* AppLookupPlugin.swift */,
6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */,
6AD8B1652ED7B50000E9E1B4 /* MpvPlayer */,
6AD8B16C2ED7B50000E9E1B7 /* Runner-Bridging-Header.h */,
@@ -496,6 +499,7 @@
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */,
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */,
6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */,
+ 6AD8B1702ED7B50000E9E1C1 /* AppLookupPlugin.swift in Sources */,
6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */,
6AD8B16A2ED7B50000E9E1B6 /* MpvPipController.swift in Sources */,
);
diff --git a/macos/Runner/AppLookupPlugin.swift b/macos/Runner/AppLookupPlugin.swift
new file mode 100644
index 00000000..7adcf70c
--- /dev/null
+++ b/macos/Runner/AppLookupPlugin.swift
@@ -0,0 +1,36 @@
+import Cocoa
+import FlutterMacOS
+
+// MARK: - AppLookupPlugin
+/// Answers "is this application installed?" from Launch Services, the same
+/// database `open -a` resolves through. Spotlight is deliberately not used:
+/// an empty `mdfind` result also means indexing is off or incomplete, which
+/// would hide players the user actually has.
+class AppLookupPlugin: NSObject, FlutterPlugin {
+ static func register(with registrar: FlutterPluginRegistrar) {
+ let channel = FlutterMethodChannel(
+ name: "com.plezy/app_lookup",
+ binaryMessenger: registrar.messenger
+ )
+ registrar.addMethodCallDelegate(AppLookupPlugin(), channel: channel)
+ }
+
+ func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
+ switch call.method {
+ case "isApplicationInstalled":
+ guard let args = call.arguments as? [String: Any],
+ let bundleId = args["bundleId"] as? String, !bundleId.isEmpty
+ else {
+ result(
+ FlutterError(
+ code: "INVALID_ARGUMENTS", message: "bundleId is required", details: nil))
+ return
+ }
+ let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId)
+ result(url != nil)
+
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+}
diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift
index 82c442a2..144e29e0 100644
--- a/macos/Runner/MainFlutterWindow.swift
+++ b/macos/Runner/MainFlutterWindow.swift
@@ -41,6 +41,10 @@ class MainFlutterWindow: NSWindow {
WindowUtilsPlugin.installWindowDelegate()
WindowUtilsPlugin.syncWindowChrome()
+ // Register Launch Services lookups used to detect installed external players
+ AppLookupPlugin.register(
+ with: flutterViewController.registrar(forPlugin: "AppLookupPlugin"))
+
RegisterGeneratedPlugins(registry: flutterViewController)
// Enable window position/size persistence
diff --git a/test/models/external_player_models_test.dart b/test/models/external_player_models_test.dart
index 09d7c6d0..93e5a6ae 100644
--- a/test/models/external_player_models_test.dart
+++ b/test/models/external_player_models_test.dart
@@ -1,22 +1,244 @@
import 'dart:io';
+import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/external_player_models.dart';
+/// The POSIX one-liner `_posixCommandExists` hands to `sh`.
+const _commandProbe = r'command -v "$1" >/dev/null 2>&1';
+
void main() {
- test('Linux exposes only known players whose commands are available', () {
- final players = KnownPlayers.getForCurrentPlatform();
+ setUp(KnownPlayers.resetForTesting);
+ tearDown(KnownPlayers.resetForTesting);
- expect(players.map((player) => player.id), [
- 'system_default',
- if (_commandIsAvailable('vlc')) 'vlc',
- if (_commandIsAvailable('mpv')) 'mpv',
- if (_commandIsAvailable('celluloid')) 'celluloid',
- ]);
+ group('detection failures', () {
+ test('a probe that throws lists the player instead of hiding it', () async {
+ final installed = await _resolveWith(_FakeProbe.everythingInstalled());
+ final failed = await _resolveWith(_FakeProbe(failEveryLookup: true));
+
+ expect(failed, installed);
+ });
+
+ test('detection runs once per process', () async {
+ final probe = _FakeProbe.everythingInstalled();
+ KnownPlayers.probe = probe;
+
+ final first = await KnownPlayers.getForCurrentPlatform();
+ final runsAfterFirst = probe.runs.length;
+ final second = await KnownPlayers.getForCurrentPlatform();
+
+ expect(second, same(first));
+ expect(probe.runs, hasLength(runsAfterFirst));
+ });
+
+ test('undetected players keep their declared order', () async {
+ final all = await _resolveWith(_FakeProbe.everythingInstalled());
+ final none = await _resolveWith(_FakeProbe());
+
+ expect(none.first, 'system_default');
+ expect(none, all.where(none.contains));
+ });
+ });
+
+ // Which detectors run is driven by the probe, so every platform's wiring is
+ // exercised on whatever host runs the suite.
+ group('detector wiring', () {
+ test('Linux asks the shell to resolve each command on PATH', () async {
+ final probe = _FakeProbe(operatingSystem: 'linux');
+ await _resolveWith(probe);
+
+ // Detectors run concurrently, so assert what is probed, not the order.
+ expect(
+ probe.runs,
+ unorderedEquals([
+ ['sh', '-c', _commandProbe, 'plezy-command-probe', 'vlc'],
+ ['sh', '-c', _commandProbe, 'plezy-command-probe', 'mpv'],
+ ['sh', '-c', _commandProbe, 'plezy-command-probe', 'celluloid'],
+ ]),
+ );
+ });
+
+ test('macOS asks Launch Services for bundles and the shell for mpv', () async {
+ final probe = _FakeProbe(operatingSystem: 'macos');
+ await _resolveWith(probe);
+
+ expect(probe.bundleLookups, unorderedEquals(['org.videolan.vlc', 'com.colliderli.iina']));
+ expect(probe.runs, [
+ ['sh', '-c', _commandProbe, 'plezy-command-probe', 'mpv'],
+ ]);
+ });
+
+ test('Windows falls back to where.exe when no install path matches', () async {
+ final probe = _FakeProbe(operatingSystem: 'windows');
+ await _resolveWith(probe);
+
+ expect(
+ probe.runs,
+ unorderedEquals([
+ ['where.exe', 'vlc'],
+ ['where.exe', 'mpv'],
+ ['where.exe', 'PotPlayerMini64'],
+ ]),
+ );
+ });
+
+ test('Windows skips where.exe once a concrete VLC install path matches', () async {
+ final probe = _FakeProbe(
+ operatingSystem: 'windows',
+ paths: {r'C:\Program Files\VideoLAN\VLC\vlc.exe'},
+ schemes: {'potplayer://'},
+ );
+ await _resolveWith(probe);
+
+ expect(probe.runs, [
+ ['where.exe', 'mpv'],
+ ]);
+ });
+
+ test('iOS asks the same URL-handler question the launcher gates on', () async {
+ final probe = _FakeProbe(operatingSystem: 'ios');
+ await _resolveWith(probe);
+
+ expect(probe.schemeLookups, unorderedEquals(['vlc://', 'infuse://']));
+ expect(probe.runs, isEmpty);
+ });
+
+ test('platforms without a detector probe nothing', () async {
+ final probe = _FakeProbe(operatingSystem: 'android');
+ await _resolveWith(probe);
+
+ expect(probe.runs, isEmpty);
+ expect(probe.bundleLookups, isEmpty);
+ expect(probe.schemeLookups, isEmpty);
+ });
+ });
+
+ group('Linux', () {
+ test('lists only players whose command resolves on PATH', () async {
+ expect(await _resolveWith(_FakeProbe(commands: {'mpv'})), ['system_default', 'mpv']);
+ expect(await _resolveWith(_FakeProbe(commands: {'vlc', 'celluloid'})), ['system_default', 'vlc', 'celluloid']);
+ expect(await _resolveWith(_FakeProbe()), ['system_default']);
+ });
}, skip: !Platform.isLinux);
+
+ group('macOS', () {
+ test('lists only applications Launch Services knows about', () async {
+ expect(await _resolveWith(_FakeProbe(bundleIds: {'org.videolan.vlc'})), ['system_default', 'vlc']);
+ expect(await _resolveWith(_FakeProbe(bundleIds: {'com.colliderli.iina'})), ['system_default', 'iina']);
+ expect(await _resolveWith(_FakeProbe()), ['system_default']);
+ });
+
+ test('detects mpv on PATH rather than as a bundle', () async {
+ expect(await _resolveWith(_FakeProbe(commands: {'mpv'})), ['system_default', 'mpv']);
+ });
+ }, skip: !Platform.isMacOS);
+
+ group('Windows', () {
+ test('accepts VLC from a concrete install path', () async {
+ final probe = _FakeProbe(
+ paths: {r'C:\Program Files\VideoLAN\VLC\vlc.exe'},
+ environment: {'ProgramFiles': r'C:\Program Files'},
+ );
+
+ expect(await _resolveWith(probe), ['system_default', 'vlc']);
+ });
+
+ test('falls back to where.exe when no install path matches', () async {
+ final probe = _FakeProbe(commands: {'vlc', 'mpv'});
+
+ expect(await _resolveWith(probe), ['system_default', 'vlc', 'mpv']);
+ expect(probe.runs, contains(equals(['where.exe', 'vlc'])));
+ });
+
+ test('accepts PotPlayer from its registered URL handler', () async {
+ final probe = _FakeProbe(schemes: {'potplayer://'});
+
+ expect(await _resolveWith(probe), ['system_default', 'potplayer']);
+ });
+
+ test('accepts PotPlayer from PATH when no handler is registered', () async {
+ expect(await _resolveWith(_FakeProbe(commands: {'PotPlayerMini64'})), ['system_default', 'potplayer']);
+ });
+ }, skip: !Platform.isWindows);
}
-bool _commandIsAvailable(String command) {
- final result = Process.runSync('sh', ['-c', r'command -v "$1" >/dev/null 2>&1', 'plezy-test-command-probe', command]);
- return result.exitCode == 0;
+Future> _resolveWith(PlayerInstallProbe probe) async {
+ KnownPlayers.resetForTesting();
+ KnownPlayers.probe = probe;
+ final players = await KnownPlayers.getForCurrentPlatform();
+ return players.map((player) => player.id).toList();
+}
+
+/// Answers the host lookups from fixed sets, and records the subprocesses and
+/// bundle lookups it is asked for so tests can assert what production issues.
+class _FakeProbe extends PlayerInstallProbe {
+ _FakeProbe({
+ this.commands = const {},
+ this.paths = const {},
+ this.bundleIds = const {},
+ this.schemes = const {},
+ this.environment = const {},
+ this.failEveryLookup = false,
+ String? operatingSystem,
+ }) : operatingSystem = operatingSystem ?? Platform.operatingSystem,
+ _everything = false;
+
+ _FakeProbe.everythingInstalled()
+ : commands = const {},
+ paths = const {},
+ bundleIds = const {},
+ schemes = const {},
+ environment = const {},
+ failEveryLookup = false,
+ operatingSystem = Platform.operatingSystem,
+ _everything = true;
+
+ final Set commands;
+ final Set paths;
+ final Set bundleIds;
+ final Set schemes;
+ final bool failEveryLookup;
+ final bool _everything;
+ final List> runs = [];
+ final List bundleLookups = [];
+ final List schemeLookups = [];
+
+ @override
+ final Map environment;
+
+ @override
+ final String operatingSystem;
+
+ @override
+ Future run(String executable, List arguments) async {
+ runs.add([executable, ...arguments]);
+ if (failEveryLookup) throw ProcessException(executable, arguments, 'probe unavailable');
+ if (_everything) return ProcessResult(0, 0, '', '');
+
+ switch (executable) {
+ case 'sh':
+ return ProcessResult(0, commands.contains(arguments.last) ? 0 : 1, '', '');
+ case 'where.exe':
+ return ProcessResult(0, commands.contains(arguments.single) ? 0 : 1, '', '');
+ default:
+ fail('unexpected probe subprocess: $executable $arguments');
+ }
+ }
+
+ @override
+ Future applicationInstalled(String bundleId) async {
+ bundleLookups.add(bundleId);
+ if (failEveryLookup) throw MissingPluginException('no app lookup plugin');
+ return _everything || bundleIds.contains(bundleId);
+ }
+
+ @override
+ Future fileExists(String path) async => _everything || paths.contains(path);
+
+ @override
+ Future schemeHasHandler(String scheme) async {
+ schemeLookups.add(scheme);
+ if (failEveryLookup) throw MissingPluginException('no url launcher plugin');
+ return _everything || schemes.contains(scheme);
+ }
}
diff --git a/test/screens/settings/external_player_screen_test.dart b/test/screens/settings/external_player_screen_test.dart
index 1365212f..e26b07c7 100644
--- a/test/screens/settings/external_player_screen_test.dart
+++ b/test/screens/settings/external_player_screen_test.dart
@@ -1,3 +1,5 @@
+import 'dart:io';
+
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_button.dart';
@@ -16,12 +18,15 @@ void main() {
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
+ KnownPlayers.resetForTesting();
+ KnownPlayers.probe = const _AllPlayersInstalled();
settings = await SettingsService.getInstance();
LocaleSettings.setLocaleSync(AppLocale.en);
});
tearDown(() {
SettingsService.resetForTesting();
+ KnownPlayers.resetForTesting();
});
testWidgets('only custom players expose a focusable delete action', (tester) async {
@@ -43,7 +48,7 @@ void main() {
await tester.pumpWidget(MaterialApp(theme: monoTheme(dark: true), home: const ExternalPlayerScreen()));
await tester.pumpAndSettle();
- for (final player in KnownPlayers.getForCurrentPlatform()) {
+ for (final player in await KnownPlayers.getForCurrentPlatform()) {
final title = player.id == KnownPlayers.systemDefault.id ? 'System Default' : player.name;
final row = find.widgetWithText(FocusableListTile, title);
expect(row, findsOneWidget);
@@ -66,4 +71,63 @@ void main() {
expect(settings.read(SettingsService.selectedExternalPlayer), KnownPlayers.systemDefault);
expect(find.text(customPlayer.name), findsNothing);
});
+
+ testWidgets('a selected known player stays listed when detection misses it', (tester) async {
+ tester.view.devicePixelRatio = 1;
+ tester.view.physicalSize = const Size(1000, 1400);
+ addTearDown(tester.view.resetDevicePixelRatio);
+ addTearDown(tester.view.resetPhysicalSize);
+
+ // VLC is supported on every platform Plezy ships, so with detection
+ // reporting nothing installed it is always one of the filtered-out ids.
+ KnownPlayers.resetForTesting();
+ KnownPlayers.probe = const _NoPlayersInstalled();
+ final selected = KnownPlayers.findById('vlc')!;
+ expect(selected.isAvailable, isTrue);
+ expect((await KnownPlayers.getForCurrentPlatform()).map((p) => p.id), isNot(contains('vlc')));
+
+ await settings.write(SettingsService.useExternalPlayer, true);
+ await settings.write(SettingsService.selectedExternalPlayer, selected);
+
+ await tester.pumpWidget(MaterialApp(theme: monoTheme(dark: true), home: const ExternalPlayerScreen()));
+ await tester.pumpAndSettle();
+
+ expect(find.widgetWithText(FocusableListTile, selected.name), findsOneWidget);
+ expect(find.widgetWithText(FocusableListTile, 'System Default'), findsOneWidget);
+ });
+}
+
+/// Reports every player as installed so the screen renders the full platform
+/// list regardless of what the host running the suite happens to have.
+class _AllPlayersInstalled extends PlayerInstallProbe {
+ const _AllPlayersInstalled();
+
+ @override
+ Future run(String executable, List arguments) async => ProcessResult(0, 0, '', '');
+
+ @override
+ Future applicationInstalled(String bundleId) async => true;
+
+ @override
+ Future fileExists(String path) async => true;
+
+ @override
+ Future schemeHasHandler(String scheme) async => true;
+}
+
+/// Reports nothing as installed, so only players without a detector survive.
+class _NoPlayersInstalled extends PlayerInstallProbe {
+ const _NoPlayersInstalled();
+
+ @override
+ Future run(String executable, List arguments) async => ProcessResult(0, 1, '', '');
+
+ @override
+ Future applicationInstalled(String bundleId) async => false;
+
+ @override
+ Future fileExists(String path) async => false;
+
+ @override
+ Future schemeHasHandler(String scheme) async => false;
}
diff --git a/tvos/Runner/Info.plist b/tvos/Runner/Info.plist
index 65f2a95f..f6815021 100644
--- a/tvos/Runner/Info.plist
+++ b/tvos/Runner/Info.plist
@@ -33,6 +33,13 @@
+
+ LSApplicationQueriesSchemes
+
+ vlc
+ infuse
+
GCSupportedGameControllers