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:
@@ -26,6 +26,13 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<!-- canOpenURL returns false for undeclared schemes, which both blocks the
|
||||||
|
external-player handoff and hides the players from Settings. -->
|
||||||
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>vlc</string>
|
||||||
|
<string>infuse</string>
|
||||||
|
</array>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import 'profiles/profile_connection_cleanup.dart';
|
|||||||
import 'profiles/profile_connection_registry.dart';
|
import 'profiles/profile_connection_registry.dart';
|
||||||
import 'profiles/profile_registry.dart';
|
import 'profiles/profile_registry.dart';
|
||||||
import 'profiles/profile_selection_policy.dart';
|
import 'profiles/profile_selection_policy.dart';
|
||||||
|
import 'models/external_player_models.dart';
|
||||||
import 'mixins/mounted_set_state_mixin.dart';
|
import 'mixins/mounted_set_state_mixin.dart';
|
||||||
import 'theme/mono_theme.dart';
|
import 'theme/mono_theme.dart';
|
||||||
import 'profiles/plex_home_service.dart';
|
import 'profiles/plex_home_service.dart';
|
||||||
@@ -487,6 +488,9 @@ void _startNonessentialInitialization(SettingsService settings) {
|
|||||||
|
|
||||||
if (PlatformDetector.isDesktopOS()) {
|
if (PlatformDetector.isDesktopOS()) {
|
||||||
bestEffort('Discord RPC', DiscordRPCService.instance.initialize);
|
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)) {
|
if (settings.read(SettingsService.crashReporting)) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
@@ -8,6 +10,31 @@ typedef PlayerLauncher = Future<bool> Function(String url);
|
|||||||
|
|
||||||
enum CustomPlayerType { command, urlScheme }
|
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 {
|
class ExternalPlayer {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
@@ -204,19 +231,6 @@ class KnownPlayers {
|
|||||||
return _androidPackageMap[id] ?? const [];
|
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) {
|
static List<String> androidPackageCandidates(ExternalPlayer player) {
|
||||||
final knownPackages = _androidPackageCandidatesForId(player.id);
|
final knownPackages = _androidPackageCandidatesForId(player.id);
|
||||||
if (knownPackages.isNotEmpty) return knownPackages;
|
if (knownPackages.isNotEmpty) return knownPackages;
|
||||||
@@ -233,12 +247,7 @@ class KnownPlayers {
|
|||||||
id: 'vlc',
|
id: 'vlc',
|
||||||
name: 'VLC',
|
name: 'VLC',
|
||||||
iconAsset: 'assets/player_icons/vlc.svg',
|
iconAsset: 'assets/player_icons/vlc.svg',
|
||||||
isAvailable:
|
isAvailable: Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux || Platform.isWindows,
|
||||||
Platform.isAndroid ||
|
|
||||||
Platform.isIOS ||
|
|
||||||
Platform.isMacOS ||
|
|
||||||
_isLinuxCommandAvailable('vlc') ||
|
|
||||||
Platform.isWindows,
|
|
||||||
launch: (url) {
|
launch: (url) {
|
||||||
if (Platform.isAndroid) return _launchAndroidIntentCandidates(url, _androidPackageCandidatesForId('vlc'));
|
if (Platform.isAndroid) return _launchAndroidIntentCandidates(url, _androidPackageCandidatesForId('vlc'));
|
||||||
if (Platform.isIOS) return _launchUrlScheme('vlc://', url);
|
if (Platform.isIOS) return _launchUrlScheme('vlc://', url);
|
||||||
@@ -251,7 +260,7 @@ class KnownPlayers {
|
|||||||
id: 'mpv',
|
id: 'mpv',
|
||||||
name: 'mpv',
|
name: 'mpv',
|
||||||
iconAsset: 'assets/player_icons/mpv.svg',
|
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) {
|
launch: (url) {
|
||||||
if (Platform.isAndroid) return _launchAndroidIntentCandidates(url, _androidPackageCandidatesForId('mpv'));
|
if (Platform.isAndroid) return _launchAndroidIntentCandidates(url, _androidPackageCandidatesForId('mpv'));
|
||||||
return _launchCommand('mpv', url);
|
return _launchCommand('mpv', url);
|
||||||
@@ -299,14 +308,121 @@ class KnownPlayers {
|
|||||||
id: 'celluloid',
|
id: 'celluloid',
|
||||||
name: 'Celluloid',
|
name: 'Celluloid',
|
||||||
iconAsset: 'assets/player_icons/celluloid.svg',
|
iconAsset: 'assets/player_icons/celluloid.svg',
|
||||||
isAvailable: _isLinuxCommandAvailable('celluloid'),
|
isAvailable: Platform.isLinux,
|
||||||
launch: (url) => _launchCommand('celluloid', url),
|
launch: (url) => _launchCommand('celluloid', url),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Get players available on the current platform
|
/// Host lookups used to decide whether a supported player is actually
|
||||||
static List<ExternalPlayer> getForCurrentPlatform() {
|
/// installed. Overridden in tests; production talks to the real process,
|
||||||
return _allPlayers.where((p) => p.isAvailable).toList();
|
/// 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
|
/// Find a known player by ID
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ class ExternalPlayerScreen extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final knownPlayers = KnownPlayers.getForCurrentPlatform();
|
|
||||||
return SettingsPage(
|
return SettingsPage(
|
||||||
title: Text(t.externalPlayer.title),
|
title: Text(t.externalPlayer.title),
|
||||||
children: [
|
children: [
|
||||||
@@ -51,9 +50,19 @@ class ExternalPlayerScreen extends StatelessWidget {
|
|||||||
final custom = svc.read(SettingsService.customExternalPlayers);
|
final custom = svc.read(SettingsService.customExternalPlayers);
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
SettingsGroup(
|
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,
|
title: t.externalPlayer.selectPlayer,
|
||||||
children: [for (final p in knownPlayers) _PlayerTile(player: p, selectedId: selected.id)],
|
children: [
|
||||||
|
for (final p in _withSelected(detected, selected.id))
|
||||||
|
_PlayerTile(player: p, selectedId: selected.id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
SettingsGroup(
|
SettingsGroup(
|
||||||
title: t.externalPlayer.customPlayers,
|
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 {
|
class _PlayerTile extends StatelessWidget {
|
||||||
final ExternalPlayer player;
|
final ExternalPlayer player;
|
||||||
final String selectedId;
|
final String selectedId;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */; };
|
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */; };
|
||||||
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */; };
|
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */; };
|
||||||
6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.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 */; };
|
6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */; };
|
||||||
6AD8B16A2ED7B50000E9E1B6 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */; };
|
6AD8B16A2ED7B50000E9E1B6 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */; };
|
||||||
B1D51A6A2F00110000000003 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.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 = "<group>"; };
|
6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = "<group>"; };
|
||||||
6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = "<group>"; };
|
6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = "<group>"; };
|
||||||
6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowUtilsPlugin.swift; sourceTree = "<group>"; };
|
6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowUtilsPlugin.swift; sourceTree = "<group>"; };
|
||||||
|
6AD8B1712ED7B50000E9E1C1 /* AppLookupPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLookupPlugin.swift; sourceTree = "<group>"; };
|
||||||
6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDelegate.swift; sourceTree = "<group>"; };
|
6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDelegate.swift; sourceTree = "<group>"; };
|
||||||
6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = "<group>"; };
|
6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = "<group>"; };
|
||||||
6AD8B16C2ED7B50000E9E1B7 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
6AD8B16C2ED7B50000E9E1B7 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
@@ -216,6 +218,7 @@
|
|||||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
|
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
|
||||||
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
|
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
|
||||||
6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */,
|
6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */,
|
||||||
|
6AD8B1712ED7B50000E9E1C1 /* AppLookupPlugin.swift */,
|
||||||
6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */,
|
6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */,
|
||||||
6AD8B1652ED7B50000E9E1B4 /* MpvPlayer */,
|
6AD8B1652ED7B50000E9E1B4 /* MpvPlayer */,
|
||||||
6AD8B16C2ED7B50000E9E1B7 /* Runner-Bridging-Header.h */,
|
6AD8B16C2ED7B50000E9E1B7 /* Runner-Bridging-Header.h */,
|
||||||
@@ -496,6 +499,7 @@
|
|||||||
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */,
|
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */,
|
||||||
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */,
|
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */,
|
||||||
6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */,
|
6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */,
|
||||||
|
6AD8B1702ED7B50000E9E1C1 /* AppLookupPlugin.swift in Sources */,
|
||||||
6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */,
|
6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */,
|
||||||
6AD8B16A2ED7B50000E9E1B6 /* MpvPipController.swift in Sources */,
|
6AD8B16A2ED7B50000E9E1B6 /* MpvPipController.swift in Sources */,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,10 @@ class MainFlutterWindow: NSWindow {
|
|||||||
WindowUtilsPlugin.installWindowDelegate()
|
WindowUtilsPlugin.installWindowDelegate()
|
||||||
WindowUtilsPlugin.syncWindowChrome()
|
WindowUtilsPlugin.syncWindowChrome()
|
||||||
|
|
||||||
|
// Register Launch Services lookups used to detect installed external players
|
||||||
|
AppLookupPlugin.register(
|
||||||
|
with: flutterViewController.registrar(forPlugin: "AppLookupPlugin"))
|
||||||
|
|
||||||
RegisterGeneratedPlugins(registry: flutterViewController)
|
RegisterGeneratedPlugins(registry: flutterViewController)
|
||||||
|
|
||||||
// Enable window position/size persistence
|
// Enable window position/size persistence
|
||||||
|
|||||||
@@ -1,22 +1,244 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/models/external_player_models.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() {
|
void main() {
|
||||||
test('Linux exposes only known players whose commands are available', () {
|
setUp(KnownPlayers.resetForTesting);
|
||||||
final players = KnownPlayers.getForCurrentPlatform();
|
tearDown(KnownPlayers.resetForTesting);
|
||||||
|
|
||||||
expect(players.map((player) => player.id), [
|
group('detection failures', () {
|
||||||
'system_default',
|
test('a probe that throws lists the player instead of hiding it', () async {
|
||||||
if (_commandIsAvailable('vlc')) 'vlc',
|
final installed = await _resolveWith(_FakeProbe.everythingInstalled());
|
||||||
if (_commandIsAvailable('mpv')) 'mpv',
|
final failed = await _resolveWith(_FakeProbe(failEveryLookup: true));
|
||||||
if (_commandIsAvailable('celluloid')) 'celluloid',
|
|
||||||
|
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);
|
}, 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) {
|
Future<List<String>> _resolveWith(PlayerInstallProbe probe) async {
|
||||||
final result = Process.runSync('sh', ['-c', r'command -v "$1" >/dev/null 2>&1', 'plezy-test-command-probe', command]);
|
KnownPlayers.resetForTesting();
|
||||||
return result.exitCode == 0;
|
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<String> commands;
|
||||||
|
final Set<String> paths;
|
||||||
|
final Set<String> bundleIds;
|
||||||
|
final Set<String> schemes;
|
||||||
|
final bool failEveryLookup;
|
||||||
|
final bool _everything;
|
||||||
|
final List<List<String>> runs = [];
|
||||||
|
final List<String> bundleLookups = [];
|
||||||
|
final List<String> schemeLookups = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
final Map<String, String> environment;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String operatingSystem;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProcessResult> run(String executable, List<String> 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<bool> applicationInstalled(String bundleId) async {
|
||||||
|
bundleLookups.add(bundleId);
|
||||||
|
if (failEveryLookup) throw MissingPluginException('no app lookup plugin');
|
||||||
|
return _everything || bundleIds.contains(bundleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> fileExists(String path) async => _everything || paths.contains(path);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> schemeHasHandler(String scheme) async {
|
||||||
|
schemeLookups.add(scheme);
|
||||||
|
if (failEveryLookup) throw MissingPluginException('no url launcher plugin');
|
||||||
|
return _everything || schemes.contains(scheme);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/focus/focusable_button.dart';
|
import 'package:plezy/focus/focusable_button.dart';
|
||||||
@@ -16,12 +18,15 @@ void main() {
|
|||||||
setUp(() async {
|
setUp(() async {
|
||||||
resetSharedPreferencesForTest();
|
resetSharedPreferencesForTest();
|
||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
|
KnownPlayers.resetForTesting();
|
||||||
|
KnownPlayers.probe = const _AllPlayersInstalled();
|
||||||
settings = await SettingsService.getInstance();
|
settings = await SettingsService.getInstance();
|
||||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
|
KnownPlayers.resetForTesting();
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('only custom players expose a focusable delete action', (tester) async {
|
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.pumpWidget(MaterialApp(theme: monoTheme(dark: true), home: const ExternalPlayerScreen()));
|
||||||
await tester.pumpAndSettle();
|
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 title = player.id == KnownPlayers.systemDefault.id ? 'System Default' : player.name;
|
||||||
final row = find.widgetWithText(FocusableListTile, title);
|
final row = find.widgetWithText(FocusableListTile, title);
|
||||||
expect(row, findsOneWidget);
|
expect(row, findsOneWidget);
|
||||||
@@ -66,4 +71,63 @@ void main() {
|
|||||||
expect(settings.read(SettingsService.selectedExternalPlayer), KnownPlayers.systemDefault);
|
expect(settings.read(SettingsService.selectedExternalPlayer), KnownPlayers.systemDefault);
|
||||||
expect(find.text(customPlayer.name), findsNothing);
|
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<ProcessResult> run(String executable, List<String> arguments) async => ProcessResult(0, 0, '', '');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> applicationInstalled(String bundleId) async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> fileExists(String path) async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> schemeHasHandler(String scheme) async => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports nothing as installed, so only players without a detector survive.
|
||||||
|
class _NoPlayersInstalled extends PlayerInstallProbe {
|
||||||
|
const _NoPlayersInstalled();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProcessResult> run(String executable, List<String> arguments) async => ProcessResult(0, 1, '', '');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> applicationInstalled(String bundleId) async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> fileExists(String path) async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> schemeHasHandler(String scheme) async => false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,13 @@
|
|||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
<!-- canOpenURL returns false for undeclared schemes, which both blocks the
|
||||||
|
external-player handoff and hides the players from Settings. -->
|
||||||
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>vlc</string>
|
||||||
|
<string>infuse</string>
|
||||||
|
</array>
|
||||||
<key>GCSupportedGameControllers</key>
|
<key>GCSupportedGameControllers</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
Reference in New Issue
Block a user