From 31b36897405f91eee076fb57a1c6d57232fcef75 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:54:31 +0200 Subject: [PATCH] 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. --- ios/Runner/Info.plist | 7 + lib/main.dart | 4 + lib/models/external_player_models.dart | 164 ++++++++++-- .../settings/external_player_screen.dart | 26 +- macos/Runner.xcodeproj/project.pbxproj | 4 + macos/Runner/AppLookupPlugin.swift | 36 +++ macos/Runner/MainFlutterWindow.swift | 4 + test/models/external_player_models_test.dart | 244 +++++++++++++++++- .../settings/external_player_screen_test.dart | 66 ++++- tvos/Runner/Info.plist | 7 + 10 files changed, 522 insertions(+), 40 deletions(-) create mode 100644 macos/Runner/AppLookupPlugin.swift 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