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.
264 lines
9.6 KiB
Dart
264 lines
9.6 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:plezy/widgets/app_icon.dart';
|
|
|
|
import '../../widgets/dialog_action_button.dart';
|
|
import '../../focus/focusable_button.dart';
|
|
import '../../focus/focusable_text_field.dart';
|
|
import '../../i18n/strings.g.dart';
|
|
import '../../models/external_player_models.dart';
|
|
import '../../services/settings_service.dart';
|
|
import '../../utils/dialogs.dart';
|
|
import '../../widgets/expressive_button_group.dart';
|
|
import '../../widgets/focusable_list_tile.dart';
|
|
import '../../widgets/setting_tile.dart';
|
|
import '../../widgets/settings_builder.dart';
|
|
import '../../widgets/settings_page.dart';
|
|
import '../../widgets/settings_section.dart';
|
|
|
|
class ExternalPlayerScreen extends StatelessWidget {
|
|
const ExternalPlayerScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SettingsPage(
|
|
title: Text(t.externalPlayer.title),
|
|
children: [
|
|
SettingsGroup(
|
|
children: [
|
|
SettingSwitchTile(
|
|
pref: SettingsService.useExternalPlayer,
|
|
icon: Symbols.open_in_new_rounded,
|
|
title: t.externalPlayer.useExternalPlayer,
|
|
subtitle: t.externalPlayer.useExternalPlayerDescription,
|
|
),
|
|
],
|
|
),
|
|
SettingsBuilder(
|
|
prefs: [
|
|
SettingsService.useExternalPlayer,
|
|
SettingsService.selectedExternalPlayer,
|
|
SettingsService.customExternalPlayers,
|
|
],
|
|
builder: (context) {
|
|
final svc = SettingsService.instance;
|
|
if (!svc.read(SettingsService.useExternalPlayer)) return const SizedBox.shrink();
|
|
final selected = svc.read(SettingsService.selectedExternalPlayer);
|
|
final custom = svc.read(SettingsService.customExternalPlayers);
|
|
return Column(
|
|
children: [
|
|
FutureBuilder<List<ExternalPlayer>>(
|
|
future: KnownPlayers.getForCurrentPlatform(),
|
|
builder: (context, snapshot) {
|
|
final detected = snapshot.data;
|
|
if (detected == null) return const SizedBox.shrink();
|
|
return SettingsGroup(
|
|
title: t.externalPlayer.selectPlayer,
|
|
children: [
|
|
for (final p in _withSelected(detected, selected.id))
|
|
_PlayerTile(player: p, selectedId: selected.id),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
SettingsGroup(
|
|
title: t.externalPlayer.customPlayers,
|
|
children: [
|
|
for (final p in custom) _PlayerTile(player: p, selectedId: selected.id, isCustom: true),
|
|
FocusableListTile(
|
|
leading: const AppIcon(Symbols.add_rounded, fill: 1),
|
|
title: Text(t.externalPlayer.addCustomPlayer),
|
|
onTap: () => _showAddCustomPlayerDialog(context),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 24),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Keeps the current choice on screen when detection missed it, so a false
|
|
/// negative cannot leave the list with nothing selected.
|
|
List<ExternalPlayer> _withSelected(List<ExternalPlayer> detected, String selectedId) {
|
|
if (detected.any((p) => p.id == selectedId)) return detected;
|
|
final selected = KnownPlayers.findById(selectedId);
|
|
if (selected == null || !selected.isAvailable) return detected;
|
|
return [...detected, selected];
|
|
}
|
|
|
|
class _PlayerTile extends StatelessWidget {
|
|
final ExternalPlayer player;
|
|
final String selectedId;
|
|
final bool isCustom;
|
|
|
|
const _PlayerTile({required this.player, required this.selectedId, this.isCustom = false});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isSelected = selectedId == player.id;
|
|
final svc = SettingsService.instance;
|
|
|
|
Widget leading;
|
|
if (player.iconAsset != null) {
|
|
leading = ClipRRect(
|
|
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
|
child: player.iconAsset!.endsWith('.svg')
|
|
? SvgPicture.asset(player.iconAsset!, width: 32, height: 32)
|
|
: Image.asset(
|
|
player.iconAsset!,
|
|
width: 32,
|
|
height: 32,
|
|
errorBuilder: (_, _, _) => const AppIcon(Symbols.play_circle_rounded, fill: 1, size: 32),
|
|
),
|
|
);
|
|
} else if (player.id == 'system_default') {
|
|
leading = const AppIcon(Symbols.open_in_new_rounded, fill: 1, size: 32);
|
|
} else {
|
|
leading = const AppIcon(Symbols.play_circle_rounded, fill: 1, size: 32);
|
|
}
|
|
|
|
return FocusableListTile(
|
|
leading: leading,
|
|
title: Text(player.id == 'system_default' ? t.externalPlayer.systemDefault : player.name),
|
|
trailing: Row(
|
|
mainAxisSize: .min,
|
|
children: [
|
|
if (isCustom)
|
|
FocusableButton(
|
|
onPressed: () => svc.removeCustomExternalPlayer(player.id),
|
|
autoScroll: false,
|
|
child: IconButton(
|
|
icon: const AppIcon(Symbols.delete_rounded, fill: 1, size: 20),
|
|
onPressed: () => svc.removeCustomExternalPlayer(player.id),
|
|
),
|
|
),
|
|
AppIcon(
|
|
isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
|
|
fill: 1,
|
|
color: isSelected ? Theme.of(context).colorScheme.primary : null,
|
|
),
|
|
],
|
|
),
|
|
onTap: () => svc.write(SettingsService.selectedExternalPlayer, player),
|
|
);
|
|
}
|
|
}
|
|
|
|
typedef _CustomPlayerDialogResult = ({String name, String value, CustomPlayerType type});
|
|
|
|
Future<void> _showAddCustomPlayerDialog(BuildContext context) async {
|
|
final result = await showScopedDialog<_CustomPlayerDialogResult>(
|
|
context: context,
|
|
builder: (_) => const _AddCustomPlayerDialog(),
|
|
);
|
|
if (result == null) return;
|
|
|
|
final id = 'custom_${DateTime.now().millisecondsSinceEpoch}';
|
|
final newPlayer = ExternalPlayer.custom(id: id, name: result.name, value: result.value, type: result.type);
|
|
|
|
final svc = SettingsService.instance;
|
|
await svc.write(SettingsService.customExternalPlayers, [
|
|
...svc.read(SettingsService.customExternalPlayers),
|
|
newPlayer,
|
|
]);
|
|
}
|
|
|
|
class _AddCustomPlayerDialog extends StatefulWidget {
|
|
const _AddCustomPlayerDialog();
|
|
|
|
@override
|
|
State<_AddCustomPlayerDialog> createState() => _AddCustomPlayerDialogState();
|
|
}
|
|
|
|
class _AddCustomPlayerDialogState extends State<_AddCustomPlayerDialog> {
|
|
final _nameController = TextEditingController();
|
|
final _valueController = TextEditingController();
|
|
final _valueFocusNode = FocusNode(debugLabel: 'CustomExternalPlayerValue');
|
|
final _saveFocusNode = FocusNode(debugLabel: 'CustomExternalPlayerSave');
|
|
CustomPlayerType _selectedType = CustomPlayerType.command;
|
|
|
|
@override
|
|
void dispose() {
|
|
_valueFocusNode.dispose();
|
|
_saveFocusNode.dispose();
|
|
_nameController.dispose();
|
|
_valueController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _submit() {
|
|
final name = _nameController.text.trim();
|
|
final value = _valueController.text.trim();
|
|
if (name.isEmpty || value.isEmpty) return;
|
|
Navigator.pop(context, (name: name, value: value, type: _selectedType));
|
|
}
|
|
|
|
({String label, String hint}) get _valueFieldInfo {
|
|
if (_selectedType == CustomPlayerType.urlScheme) {
|
|
return (label: t.externalPlayer.playerUrlScheme, hint: 'myplayer://play?url=');
|
|
}
|
|
if (Platform.isAndroid) {
|
|
return (label: t.externalPlayer.playerPackage, hint: 'com.example.player');
|
|
}
|
|
return (label: t.externalPlayer.playerCommand, hint: Platform.isMacOS ? 'mpv' : '/usr/bin/player');
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final (:label, :hint) = _valueFieldInfo;
|
|
return AlertDialog(
|
|
title: Text(t.externalPlayer.addCustomPlayer),
|
|
content: SizedBox(
|
|
width: 300,
|
|
child: Column(
|
|
mainAxisSize: .min,
|
|
children: [
|
|
FocusableTextField(
|
|
controller: _nameController,
|
|
decoration: InputDecoration(
|
|
labelText: t.externalPlayer.playerName,
|
|
hintText: t.externalPlayer.playerNameHint,
|
|
),
|
|
autofocus: true,
|
|
textInputAction: TextInputAction.next,
|
|
onSubmitted: (_) => _valueFocusNode.requestFocus(),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ExpressiveButtonGroup<CustomPlayerType>(
|
|
segments: [
|
|
ButtonSegment(
|
|
value: CustomPlayerType.command,
|
|
label: Text(Platform.isAndroid ? t.externalPlayer.playerPackage : t.externalPlayer.playerCommand),
|
|
),
|
|
ButtonSegment(value: CustomPlayerType.urlScheme, label: Text(t.externalPlayer.playerUrlScheme)),
|
|
],
|
|
selected: _selectedType,
|
|
onChanged: (value) => setState(() => _selectedType = value),
|
|
),
|
|
const SizedBox(height: 16),
|
|
FocusableTextField(
|
|
controller: _valueController,
|
|
focusNode: _valueFocusNode,
|
|
decoration: InputDecoration(labelText: label, hintText: hint),
|
|
textInputAction: TextInputAction.done,
|
|
onSubmitted: (_) => _saveFocusNode.requestFocus(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel),
|
|
DialogActionButton(focusNode: _saveFocusNode, onPressed: _submit, label: t.common.save, isPrimary: true),
|
|
],
|
|
);
|
|
}
|
|
}
|