-
-
Notifications
You must be signed in to change notification settings - Fork 14
commit 9728917
abduznik edited this page May 23, 2026
·
1 revision
Commit: 97289171ba125b9f5d5b2d1d466b6072c8f1b835
Author: abduznik
Date: 2026-04-18
Why: Adds a new feature or capability to the application.
lib/core/emulator/custom_emulator_config.dart | 46 +++++
.../strategies/custom_emulator_strategy.dart | 71 ++++++++
lib/core/emulator/strategy_registry.dart | 7 +-
lib/providers/custom_emulators_provider.dart | 45 +++++
lib/providers/romm_provider.dart | 5 +-
.../screens/settings_custom_emulators_section.dart | 195 +++++++++++++++++++++
lib/ui/screens/settings_screen.dart | 3 +
pubspec.lock | 2 +-
pubspec.yaml | 4 +-
9 files changed, 374 insertions(+), 4 deletions(-)
lib/core/emulator/custom_emulator_config.dartlib/core/emulator/strategies/custom_emulator_strategy.dartlib/core/emulator/strategy_registry.dartlib/providers/custom_emulators_provider.dartlib/providers/romm_provider.dartlib/ui/screens/settings_custom_emulators_section.dartlib/ui/screens/settings_screen.dartpubspec.lockpubspec.yaml
diff --git a/lib/core/emulator/custom_emulator_config.dart b/lib/core/emulator/custom_emulator_config.dart
new file mode 100644
index 0000000..4f65670
--- /dev/null
+++ b/lib/core/emulator/custom_emulator_config.dart
@@ -0,0 +1,46 @@
+import 'dart:convert';
+
+enum CustomSaveMethod {
+ file,
+ folder,
+}
+
+class CustomEmulatorConfig {
+ final String id;
+ final String name;
+ final List<String> platforms;
+ final String executablePath;
+ final CustomSaveMethod saveMethod;
+ final String savePath;
+ final String? savePattern; // e.g. "*.srm" for file-based
+
+ CustomEmulatorConfig({
+ required this.id,
+ required this.name,
+ required this.platforms,
+ required this.executablePath,
+ required this.saveMethod,
+ required this.savePath,
+ this.savePattern,
+ });
+
+ Map<String, dynamic> toJson() => {
+ 'id': id,
+ 'name': name,
+ 'platforms': platforms,
+ 'executablePath': executablePath,
+ 'saveMethod': saveMethod.name,
+ 'savePath': savePath,
+ 'savePattern': savePattern,
+ };
+
+ factory CustomEmulatorConfig.fromJson(Map<String, dynamic> json) => CustomEmulatorConfig(
+ id: json['id'],
+ name: json['name'],
+ platforms: List<String>.from(json['platforms']),
+ executablePath: json['executablePath'],
+ saveMethod: CustomSaveMethod.values.byName(json['saveMethod']),
+ savePath: json['savePath'],
+ savePattern: json['savePattern'],
+ );
+}
diff --git a/lib/core/emulator/strategies/custom_emulator_strategy.dart b/lib/core/emulator/strategies/custom_emulator_strategy.dart
new file mode 100644
index 0000000..b7cdf1d
--- /dev/null
+++ b/lib/core/emulator/strategies/custom_emulator_strategy.dart
@@ -0,0 +1,71 @@
+import 'dart:io' as io;
+import 'package:path/path.dart' as p;
+import 'package:freegosy/core/romm/romm_models.dart';
+import 'package:freegosy/core/storage/directory_service.dart';
+import '../emulator_strategy.dart';
+import '../custom_emulator_config.dart';
+
+class CustomEmulatorStrategy extends EmulatorStrategy {
+ final CustomEmulatorConfig config;
+ @override
+ final DirectoryService directoryService;
+
+ CustomEmulatorStrategy(this.config, this.directoryService);
+
+ @override
+ String get name => config.name;
+
+ @override
+ String get emulatorId => config.id;
+
+ @override
+ List<String> get supportedSlugs => config.platforms;
+
+ @override
+ String get windowsExecutable => config.executablePath;
+
+ @override
+ String get linuxExecutable => config.executablePath;
+
+ @override
+ bool get supportsSaveSync => true;
+
+ @override
+ Future<String?> findExecutable() async {
+ // For custom emulators, the user provides the absolute path.
+ if (await io.File(config.executablePath).exists()) {
+ return config.executablePath;
+ }
+ return null;
+ }
+
+ @override
+ String resolveSavePath(Game game) {
+ if (config.saveMethod == CustomSaveMethod.file) {
+ final pattern = config.savePattern ?? '';
+ if (pattern.contains('*')) {
+ final ext = pattern.replaceAll('*', '');
+ return p.join(config.savePath, '${game.displayName}$ext');
+ } else if (pattern.isNotEmpty) {
+ return p.join(config.savePath, pattern);
+ } else {
+ // Fallback: just game name
+ return p.join(config.savePath, game.displayName);
+ }
+ } else {
+ // Folder based
+ return p.join(config.savePath, game.displayName);
+ }
+ }
+
+ @override
+ Future<void> launch(Game game, String romPath) async {
+ final exePath = await findExecutable();
+ if (exePath == null) throw Exception('Custom emulator executable not found at: ${config.executablePath}');
+
+ final normalizedRomPath = p.absolute(p.normalize(romPath));
+
+ // We use a raw process start because custom emulators might not be in the standard EmuDeck structure
+ await io.Process.run(exePath, [normalizedRomPath], runInShell: true);
+ }
+}
diff --git a/lib/core/emulator/strategy_registry.dart b/lib/core/emulator/strategy_registry.dart
index a9cb600..4976dfd 100644
--- a/lib/core/emulator/strategy_registry.dart
+++ b/lib/core/emulator/strategy_registry.dart
@@ -20,13 +20,17 @@ import 'package:freegosy/core/emulator/strategies/xenia_strategy.dart';
import 'package:freegosy/core/emulator/emulator_registry_data.dart';
import 'package:freegosy/core/storage/directory_service.dart';
import 'package:freegosy/core/emulator/strategies/windows_strategy.dart';
+import 'package:freegosy/core/emulator/custom_emulator_config.dart';
+import 'package:freegosy/core/emulator/strategies/custom_emulator_strategy.dart';
class StrategyRegistry {
final DirectoryService _directoryService;
late final List<EmulatorStrategy> _strategies;
+ final List<CustomEmulatorConfig> _customEmulatorConfigs;
final Map<String, String> _slugPreferences = {};
- StrategyRegistry(this._directoryService) {
+ StrategyRegistry(this._directoryService, {List<CustomEmulatorConfig> customEmulators = const []})
+ : _customEmulatorConfigs = customEmulators {
final List<EmulatorStrategy> allPossibleStrategies = [
RetroArchStrategy(_directoryService),
DolphinStrategy(_directoryService),
@@ -44,6 +48,7 @@ class StrategyRegistry {
XemuStrategy(_directoryService),
XeniaStrategy(_directoryService),
WindowsStrategy(_directoryService),
+ ..._customEmulatorConfigs.map((config) => CustomEmulatorStrategy(config, _directoryService)),
];
_strategies = allPossibleStrategies.where((strategy) {
diff --git a/lib/providers/custom_emulators_provider.dart b/lib/providers/custom_emulators_provider.dart
new file mode 100644
index 0000000..6a70047
--- /dev/null
+++ b/lib/providers/custom_emulators_provider.dart
@@ -0,0 +1,45 @@
+import 'dart:convert';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import '../core/emulator/custom_emulator_config.dart';
+
+class CustomEmulatorsNotifier extends StateNotifier<List<CustomEmulatorConfig>> {
+ static const String _storageKey = 'custom_emulators_config';
+
+ CustomEmulatorsNotifier() : super([]) {
+ _load();
+ }
+
+ Future<void> _load() async {
+ final prefs = await SharedPreferences.getInstance();
+ final jsonStr = prefs.getString(_storageKey);
+ if (jsonStr != null) {
+ try {
+ final List<dynamic> list = json.decode(jsonStr);
+ state = list.map((item) => CustomEmulatorConfig.fromJson(item)).toList();
+ } catch (e) {
+ state = [];
+ }
+ }
+ }
+
+ Future<void> _save() async {
+ final prefs = await SharedPreferences.getInstance();
+ final jsonStr = json.encode(state.map((e) => e.toJson()).toList());
+ await prefs.setString(_storageKey, jsonStr);
+ }
+
+ Future<void> addEmulator(CustomEmulatorConfig config) async {
+ state = [...state, config];
+ await _save();
+ }
+
+ Future<void> removeEmulator(String id) async {
+ state = state.where((e) => e.id != id).toList();
+ await _save();
+ }
+}
+
+final customEmulatorsProvider = StateNotifierProvider<CustomEmulatorsNotifier, List<CustomEmulatorConfig>>((ref) {
+ return CustomEmulatorsNotifier();
+});
diff --git a/lib/providers/romm_provider.dart b/lib/providers/romm_provider.dart
index f26fbdb..699dff4 100644
--- a/lib/providers/romm_provider.dart
+++ b/lib/providers/romm_provider.dart
@@ -13,6 +13,7 @@ import 'package:freegosy/core/storage/download_cache_service.dart';
import 'package:freegosy/core/storage/metadata_cache_service.dart';
import 'package:freegosy/core/storage/rom_mapping_service.dart';
import 'package:freegosy/core/romm/rom_scanner_service.dart';
+import 'package:freegosy/providers/custom_emulators_provider.dart';
import 'package:freegosy/core/emulator/firmware_service.dart';
@@ -107,9 +108,11 @@ final directoryServiceProvider = FutureProvider<DirectoryService?>((ref) async {
// Provider for StrategyRegistry
final strategyRegistryProvider = FutureProvider<StrategyRegistry?>((ref) async {
final directoryService = ref.watch(directoryServiceProvider).value;
+ final customEmulators = ref.watch(customEmulatorsProvider);
+
if (directoryService != null) {
try {
- final registry = StrategyRegistry(directoryService);
+ final registry = StrategyRegistry(directoryService, customEmulators: customEmulators);
await registry.loadPreferences(); // Await preferences loading
// Load persisted Windows exe overrides
final winStrategy = registry.getStrategyForSlug('windows');
diff --git a/lib/ui/screens/settings_custom_emulators_section.dart b/lib/ui/screens/settings_custom_emulators_section.dart
new file mode 100644
index 0000000..26a2a72
--- /dev/null
+++ b/lib/ui/screens/settings_custom_emulators_section.dart
@@ -0,0 +1,195 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:file_picker/file_picker.dart';
+import 'package:uuid/uuid.dart';
+import '../../core/emulator/custom_emulator_config.dart';
+import '../../providers/custom_emulators_provider.dart';
+
+class SettingsCustomEmulatorsSection extends ConsumerWidget {
+ const SettingsCustomEmulatorsSection({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final customEmulators = ref.watch(customEmulatorsProvider);
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const Text('Custom Emulators', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
+ const SizedBox(width: 8),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(
+ color: Colors.orange.withValues(alpha: 0.2),
+ borderRadius: BorderRadius.circular(4),
+ border: Border.all(color: Colors.orange.withValues(alpha: 0.5)),
+ ),
+ child: const Text(
+ 'EXPERIMENTAL',
+ style: TextStyle(color: Colors.orange, fontSize: 10, fontWeight: FontWeight.bold),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ const Text(
+ 'Add your own emulators. Use comma-separated platform names (e.g. ps1,ps2).',
+ style: TextStyle(color: Colors.grey, fontSize: 13),
+ ),
+ const SizedBox(height: 16),
+ if (customEmulators.isEmpty)
+ const Center(
+ child: Padding(
+ padding: EdgeInsets.all(16.0),
+ child: Text('No custom emulators added yet.', style: TextStyle(color: Colors.grey)),
+ ),
+ ),
+ ...customEmulators.map((emu) => Card(
+ margin: const EdgeInsets.only(bottom: 8),
+ child: ListTile(
+ title: Text(emu.name),
+ subtitle: Text('${emu.platforms.join(", ")}\n${emu.executablePath}'),
+ trailing: IconButton(
+ icon: const Icon(Icons.delete_outline, color: Colors.red),
+ onPressed: () => ref.read(customEmulatorsProvider.notifier).removeEmulator(emu.id),
+ ),
+ isThreeLine: true,
+ ),
+ )),
+ const SizedBox(height: 8),
+ SizedBox(
+ width: double.infinity,
+ child: ElevatedButton.icon(
+ onPressed: () => _showAddEmulatorDialog(context, ref),
+ icon: const Icon(Icons.add),
+ label: const Text('Add Custom Emulator'),
+ ),
+ ),
+ ],
+ );
+ }
+
+ void _showAddEmulatorDialog(BuildContext context, WidgetRef ref) {
+ final nameController = TextEditingController();
+ final platformsController = TextEditingController();
+ final exeController = TextEditingController();
+ final savePathController = TextEditingController();
+ final patternController = TextEditingController();
+ CustomSaveMethod saveMethod = CustomSaveMethod.file;
+
+ showDialog(
+ context: context,
+ builder: (context) => StatefulBuilder(
+ builder: (context, setDialogState) => AlertDialog(
+ title: const Text('Add Custom Emulator'),
+ content: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextField(
+ controller: nameController,
+ decoration: const InputDecoration(labelText: 'Emulator Name (e.g. PCSX2 Nightly)'),
+ ),
+ TextField(
+ controller: platformsController,
+ decoration: const InputDecoration(labelText: 'Platform Slugs (e.g. ps1,ps2)'),
+ ),
+ const SizedBox(height: 16),
+ Row(
+ children: [
+ Expanded(
+ child: TextField(
+ controller: exeController,
+ decoration: const InputDecoration(labelText: 'Executable Path'),
+ readOnly: true,
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.folder_open),
+ onPressed: () async {
+ FilePickerResult? result = await FilePicker.platform.pickFiles();
+ if (result != null) {
+ exeController.text = result.files.single.path ?? '';
+ }
+ },
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ const Divider(),
+ const Text('Save Sync Settings', style: TextStyle(fontWeight: FontWeight.bold)),
+ const SizedBox(height: 8),
+ DropdownButtonFormField<CustomSaveMethod>(
+ value: saveMethod,
+ items: const [
+ DropdownMenuItem(value: CustomSaveMethod.file, child: Text('File Based')),
+ DropdownMenuItem(value: CustomSaveMethod.folder, child: Text('Folder Based')),
+ ],
+ onChanged: (val) => setDialogState(() => saveMethod = val!),
+ decoration: const InputDecoration(labelText: 'Save Method'),
+ ),
+ const SizedBox(height: 16),
+ Row(
+ children: [
+ Expanded(
+ child: TextField(
+ controller: savePathController,
+ decoration: const InputDecoration(labelText: 'Save Directory'),
+ readOnly: true,
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.folder_open),
+ onPressed: () async {
+ String? path = await FilePicker.platform.getDirectoryPath();
+ if (path != null) {
+ savePathController.text = path;
+ }
+ },
+ ),
+ ],
+ ),
+ if (saveMethod == CustomSaveMethod.file)
+ TextField(
+ controller: patternController,
+ decoration: const InputDecoration(
+ labelText: 'Save Pattern (e.g. *.srm or specific name)',
+ hintText: '*.srm',
+ ),
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
+ ElevatedButton(
+ onPressed: () {
+ if (nameController.text.isEmpty || exeController.text.isEmpty || platformsController.text.isEmpty) {
+ return;
+ }
+ final platforms = platformsController.text.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
+
+ final config = CustomEmulatorConfig(
+ id: const Uuid().v4(),
+ name: nameController.text.trim(),
+ platforms: platforms,
+ executablePath: exeController.text.trim(),
+ saveMethod: saveMethod,
+ savePath: savePathController.text.trim(),
+ savePattern: patternController.text.trim().isEmpty ? null : patternController.text.trim(),
+ );
+
+ ref.read(customEmulatorsProvider.notifier).addEmulator(config);
+ Navigator.pop(context);
+ },
+ child: const Text('Add'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart
index 0802244..3914fe1 100644
--- a/lib/ui/screens/settings_screen.dart
+++ b/lib/ui/screens/settings_screen.dart
@@ -16,6 +16,7 @@ import '../../core/romm/romm_service.dart';
import '../../core/romm/romm_models.dart';
import 'settings_emulators_section.dart';
import 'settings_display_section.dart';
+import 'settings_custom_emulators_section.dart';
class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key});
@@ -155,6 +156,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
),
error: (e, s) => Center(child: Text('Error loading emulators: $e')),
),
+ const SizedBox(height: 24),
+ const SettingsCustomEmulatorsSection(),
if (strategyRegistry != null) ...[
const SizedBox(height: 24),
// Call the extracted conflicts section function
diff --git a/pubspec.lock b/pubspec.lock
index 8b2297d..4b7a492 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -1086,7 +1086,7 @@ packages:
source: hosted
version: "3.1.5"
uuid:
- dependency: transitive
+ dependency: "direct main"
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
diff --git a/pubspec.yaml b/pubspec.yaml
index 8272c4b..28e5a4c 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -16,7 +16,8 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
-version: 0.3.2+1
+version: 0.4.0+1
+
environment:
sdk: ^3.11.1
@@ -51,6 +52,7 @@ dependencies:
hive: ^2.2.3
hive_flutter: ^1.1.0
synchronized: ^3.1.0+1
+ uuid: ^4.5.1
dev_dependencies:
flutter_test: