-
-
Notifications
You must be signed in to change notification settings - Fork 14
commit 3850059
Commit: 38500598998820b876088392ccb52630d95e3e38
Author: abduznik
Date: 2026年03月26日
-
perf: cache images, fix extraction service, emulator downloads in download tab
-
feat: extraction service, emulator downloads in download tab, image caching, performance optimizations, emulator conflict resolver, melonDS, Flycast, platform guards
-
feat: conflict resolver grouping, Flycast asset fix and extended platform support
-
feat: PPSSPP, mGBA, MAME standalones, RetroArch auto core install, MAME self-extracting exe support
-
docs: update agent.md with new emulators, extraction service, conflict resolver, performance improvements
-
feat: save strategies for DuckStation/melonDS/mGBA/PPSSPP/Cemu, fix syncMode scope to RetroArch only, emulator path overrides, Cemu save as zip
-
feat: save strategies for DuckStation/melonDS/mGBA/PPSSPP/Cemu, emulator path overrides, conflict resolver drives save sync, launch status snackbars, Cemu save zip fix, backupSave chain fix, lint cleanup, agent.md update
-
fix: Cemu save restore path extraction, preserve full zip structure
-
feat: RetroArch auto core download, MissingRetroArchCoreException, downloadCore from buildbot
-
refactor: split settings_screen into display and emulators sections, fix analyze warnings
-
fix: PSP save sync, strategy preferences load before first sync, SAVEDATA zipped as single file
-
fix: PSP saves zip correctly, directory exists check for File vs Directory, preferences await on startup
-
feat: auto-sync saves on game close, fix PSP SAVEDATA restore path, fix cold start null strategy on first launch
-
fix: melonDS launch pipe drain fix, PPSSPP save restore path, auto-sync on game close, PSP empty folder check
-
fix: analyze warnings, BuildContext async gaps, unused imports
-
fix: ZIP magic byte detection for extensionless multi-file ROMs, extraction on background isolate, remove debug prints
Why: Adds a new feature or capability to the application.
README.md | 92 ++--
agent.md | 285 +++++-----
lib/core/downloader/download_service.dart | 39 +-
lib/core/emulator/emulator_download_service.dart | 27 +-
lib/core/emulator/emulator_registry_data.dart | 78 ++-
lib/core/emulator/emulator_strategy.dart | 4 +-
lib/core/emulator/strategies/azahar_strategy.dart | 9 +
lib/core/emulator/strategies/cemu_strategy.dart | 9 +
lib/core/emulator/strategies/dolphin_strategy.dart | 7 +
.../emulator/strategies/duckstation_strategy.dart | 9 +
lib/core/emulator/strategies/eden_strategy.dart | 7 +
lib/core/emulator/strategies/flycast_strategy.dart | 49 ++
lib/core/emulator/strategies/mame_strategy.dart | 69 +++
lib/core/emulator/strategies/melonds_strategy.dart | 52 ++
lib/core/emulator/strategies/mgba_strategy.dart | 49 ++
lib/core/emulator/strategies/pcsx2_strategy.dart | 11 +
lib/core/emulator/strategies/ppsspp_strategy.dart | 49 ++
.../emulator/strategies/retroarch_strategy.dart | 93 +++-
lib/core/emulator/strategies/rpcs3_strategy.dart | 9 +
lib/core/emulator/strategies/xemu_strategy.dart | 9 +
lib/core/emulator/strategies/xenia_strategy.dart | 9 +
lib/core/emulator/strategy_registry.dart | 119 ++++-
lib/core/extraction/extraction_service.dart | 70 +++
lib/core/save/save_strategy.dart | 2 +-
lib/core/save/save_sync_service.dart | 72 ++-
lib/core/save/strategies/cemu_save_strategy.dart | 69 +++
.../save/strategies/duckstation_save_strategy.dart | 122 +++++
.../save/strategies/melonds_save_strategy.dart | 68 +++
lib/core/save/strategies/mgba_save_strategy.dart | 68 +++
lib/core/save/strategies/pcsx2_save_strategy.dart | 42 +-
lib/core/save/strategies/ppsspp_save_strategy.dart | 108 ++++
.../save/strategies/retroarch_save_strategy.dart | 110 ++--
lib/core/storage/directory_service.dart | 40 +-
lib/providers/download_provider.dart | 31 +-
lib/providers/library_provider.dart | 2 -
lib/providers/romm_provider.dart | 11 +-
lib/ui/screens/library_screen.dart | 571 ++++++++++-----------
lib/ui/screens/library_skeleton.dart | 91 ++++
lib/ui/screens/settings_display_section.dart | 165 ++++++
lib/ui/screens/settings_emulators_section.dart | 158 ++++++
lib/ui/screens/settings_screen.dart | 344 +++----------
lib/ui/widgets/game_card.dart | 179 ++++---
macos/Flutter/GeneratedPluginRegistrant.swift | 2 +
pubspec.lock | 112 ++++
pubspec.yaml | 1 +
45 files changed, 2582 insertions(+), 940 deletions(-)
README.mdagent.mdlib/core/downloader/download_service.dartlib/core/emulator/emulator_download_service.dartlib/core/emulator/emulator_registry_data.dartlib/core/emulator/emulator_strategy.dartlib/core/emulator/strategies/azahar_strategy.dartlib/core/emulator/strategies/cemu_strategy.dartlib/core/emulator/strategies/dolphin_strategy.dartlib/core/emulator/strategies/duckstation_strategy.dartlib/core/emulator/strategies/eden_strategy.dartlib/core/emulator/strategies/flycast_strategy.dartlib/core/emulator/strategies/mame_strategy.dartlib/core/emulator/strategies/melonds_strategy.dartlib/core/emulator/strategies/mgba_strategy.dartlib/core/emulator/strategies/pcsx2_strategy.dartlib/core/emulator/strategies/ppsspp_strategy.dartlib/core/emulator/strategies/retroarch_strategy.dartlib/core/emulator/strategies/rpcs3_strategy.dartlib/core/emulator/strategies/xemu_strategy.dartlib/core/emulator/strategies/xenia_strategy.dartlib/core/emulator/strategy_registry.dartlib/core/extraction/extraction_service.dartlib/core/save/save_strategy.dartlib/core/save/save_sync_service.dartlib/core/save/strategies/cemu_save_strategy.dartlib/core/save/strategies/duckstation_save_strategy.dartlib/core/save/strategies/melonds_save_strategy.dartlib/core/save/strategies/mgba_save_strategy.dartlib/core/save/strategies/pcsx2_save_strategy.dartlib/core/save/strategies/ppsspp_save_strategy.dartlib/core/save/strategies/retroarch_save_strategy.dartlib/core/storage/directory_service.dartlib/providers/download_provider.dartlib/providers/library_provider.dartlib/providers/romm_provider.dartlib/ui/screens/library_screen.dartlib/ui/screens/library_skeleton.dartlib/ui/screens/settings_display_section.dartlib/ui/screens/settings_emulators_section.dartlib/ui/screens/settings_screen.dartlib/ui/widgets/game_card.dartmacos/Flutter/GeneratedPluginRegistrant.swiftpubspec.lockpubspec.yaml
diff --git a/README.md b/README.md index c8a824d..0d041c4 100644 --- a/README.md +++ b/README.md @@ -2,32 +2,46 @@ A cross-platform Flutter app for browsing your RomM library, downloading ROMs, and launching games directly in emulators—all from one intuitive interface. -## What's New in 0.0.2 +## What's New in 0.1.0 -### Full Windows Native Game Support -- Download Windows games (.zip and .7z) with automatic extraction -- Auto-detection of game executables in extracted folders -- Manual exe and save path configuration via long-press on any Windows game card -- Crash detection on launch — if a game exits immediately, a clear error message explains likely causes (missing DirectX, Visual C++ redistributables, etc.) -- Save sync for Windows games via PCGamingWiki automatic save path detection, with manual override support -- All exe and save path overrides persist across app restarts +### New Emulator Support +- **Flycast** (Dreamcast, Naomi, Atomiswave) +- **melonDS** (Nintendo DS) +- **PPSSPP** standalone (PlayStation Portable) +- **mGBA** standalone (Game Boy Advance, GBC, GB) +- **MAME** (Arcade) ### Expanded Save Sync -- **PCSX2 (PS2)**: Syncs memory cards (Mcd001.ps2, Mcd002.ps2) and save states -- **RPCS3 (PS3)**: Syncs save data folders by title ID -- **Xenia Canary (Xbox 360)**: Syncs content save folders by title ID -- **Windows games**: Full save directory packaging into zip for upload, auto-extraction on restore -- All multi-file save strategies package saves as a single zip for clean cloud storage - -### 7-Zip Support -- Bundled 7zr.exe for .7z extraction — no manual installation required -- Automatically extracted to AppData on first run -- Full .7z support for Windows game downloads - -### Quality of Life -- Launch error messages now display for 8 seconds for better readability -- Stale exe overrides (pointing to moved/deleted files) are automatically discarded and re-detected -- ROM detection improved for Windows games — returns game folder instead of individual file +- **DuckStation** (PS1): Memory card save sync +- **melonDS** (NDS): Save file sync +- **mGBA** (GBA/GBC/GB): Save file sync +- **PPSSPP** (PSP): Entire SAVEDATA directory with automatic folder structure restoration +- **Cemu** (Wii U): Full mlc01/usr/save/ directory packaging and restore +- All new strategies support both push (upload) and pull (download) operations + +### Auto-Sync on Game Close +- Saves automatically push to cloud when you close the emulator +- Available for all supported emulators via `launchWithHandle()` +- Seamless background operation—no manual intervention needed +- Works alongside pre-launch save push and manual sync + +### Smart Emulator Management +- **Conflict Resolver**: When multiple emulators support the same platform, choose which one to use in Settings +- **Path Overrides**: Point Freegosy to existing emulator installations via Settings folder icon (no need to re-download) +- **Preference Persistence**: Your emulator choices and custom paths are saved across app restarts + +### RetroArch Core Auto-Download +- Missing cores are detected at launch time +- User is offered to auto-download and install the required core +- Full core list support via RetroArch buildbot + +### Performance & UX Enhancements +- Library grid renders without blocking—0% CPU while idle +- Cached images (memCache) for fast card rendering +- Optimized grid with extended cache extent and dual-stem state file matching for RetroArch +- **Library Display Presets**: Windows, Steam Deck, Cozy, Compact—quick-switch your layout +- **Launch Status Snackbars**: Real-time feedback showing "Pushing saves / Syncing saves / Launching / Auto-syncing" at each stage +- **Downloads Tab**: Emulator downloads now show alongside game downloads with progress tracking ## Currently Working @@ -45,39 +59,47 @@ A cross-platform Flutter app for browsing your RomM library, downloading ROMs, a - Cemu (Wii U) - Xemu (Xbox) - Xenia Canary (Xbox 360) + - Flycast (Dreamcast, Naomi, Atomiswave) + - melonDS (Nintendo DS) + - PPSSPP (PlayStation Portable) + - mGBA (Game Boy Advance/Color/Game Boy) + - MAME (Arcade) - Windows Native (PC games) - **Emulator Downloads**: One-tap emulator download and installation from Settings -- **Save Sync**: Two-way save synchronization with RomM cloud for: +- **Save Sync**: Bidirectional save synchronization with RomM cloud for: - RetroArch (all supported platforms) - Dolphin (GameCube/Wii) - Eden (Switch) - PCSX2 (PS2) - RPCS3 (PS3) + - DuckStation (PS1) + - melonDS (NDS) + - mGBA (GBA/GBC/GB) + - PPSSPP (PSP) + - Cemu (Wii U) - Xenia Canary (Xbox 360) - Windows native games (via PCGamingWiki auto-detection) ## Roadmap -### Near Term +### In Progress +- **Linux/macOS support** — Platform detection and path resolution already structured; adding platform-specific executable paths and environment variable resolution -- **Cemu save sync** — Wii U saves at mlc01/usr/save/ -- **Azahar save sync** — 3DS saves via SDMC folder -- **MelonDS support** — better NDS emulation alternative -- **macOS & Linux support** — platform detection and path resolution already structured for future expansion +### Near Term +- **Auto-update emulators** — Keep emulators fresh without manual downloads +- **Android support** — Deep links to app stores for Play Store/Epic Games/etc. +- **Recently played / play time tracking** — See your gaming stats at a glance ### End-Game Features - -- Automatic emulator updates - Custom ROM platform tagging - Mobile companion app for on-the-go library browsing ### Cross-Platform Vision - -Freegosy is designed as a truly cross-platform experience. The codebase is structured to support Windows, macOS, and Linux with platform-specific code isolated behind strategy patterns and service abstractions. +Freegosy is designed as a truly cross-platform experience. The codebase is structured to support Windows, macOS, Linux, and Android with platform-specific code isolated behind strategy patterns and service abstractions. ## Status -Actively under development. Release 0.0.2 focuses on Windows game support and expanded save sync coverage. +Actively under development. Release 0.1.0 brings major emulator expansion, comprehensive save sync, and auto-sync on game close. ## About RomM @@ -85,4 +107,4 @@ Freegosy is built to complement [RomM](https://github.com/rommapp/romm), a moder ## Contributing -Check out `agent.md` for the full file map, coding rules, and contracts for adding new emulators, save strategies, or features. \ No newline at end of file +Check out `agent.md` for the full file map, coding rules, and contracts for adding new emulators, save strategies, or features. diff --git a/agent.md b/agent.md index de0be33..183080a 100644 --- a/agent.md +++ b/agent.md @@ -1,135 +1,152 @@ -# Freegosy — Agent Map -> This file is the source of truth for all LLM agents (Claude, Gemini) working on this codebase. -> Read this before touching any file. Update this file if you create or split any file. - -## Project Overview -Freegosy is a cross-platform Flutter app for browsing a RomM library, downloading ROMs via HTTP, and launching emulators. Built with Riverpod for state management. - -## Rules (MANDATORY) -- No file exceeds 600 lines. If adding code would exceed this, split the file first and update this map. -- All RomM API calls go through `romm_service.dart` only. Never call the API directly from UI or providers. -- All emulator logic goes through the strategy pattern. Never hardcode emulator behavior in UI. -- New emulator = new file in `core/emulator/strategies/`, register in `strategy_registry.dart` only. -- New save strategy = new file in `core/save/strategies/`, register in `save_sync_service.dart` only. -- New screen = new file in `ui/screens/`. -- New reusable widget = new file in `ui/widgets/`. -- Providers are thin — they call services, they do not contain business logic. -- Never use `Platform.environment` or `Platform.isWindows` directly — causes conflicts with flutter/foundation.dart. Use `Process.run('cmd', ['/c', 'echo %APPDATA%'])` for env vars, and `defaultTargetPlatform == TargetPlatform.windows` for platform checks. - -## File Map - -### Entry Points -- `lib/main.dart` — App entry point. Initializes Riverpod ProviderScope. Calls app.dart. -- `lib/app.dart` — MaterialApp setup, theme, initial route, navigation shell. - -### Core — RomM -- `lib/core/romm/romm_service.dart` — All RomM HTTP calls (Dio). Methods: getPlatforms(), getGames(), getSaves(), uploadSave(), getLatestSave(), downloadSave(). Returns typed models. -- `lib/core/romm/romm_models.dart` — Data models: Game, Platform, SaveFile, RomMConfig. - -### Core — Save Sync -- `lib/core/save/save_strategy.dart` — Abstract base class SaveStrategy. Methods: getSaveDir(), getSaveFiles(), restoreSave(). Helpers: backupSave() (3-version rotation), getRomStem(). -- `lib/core/save/save_sync_service.dart` — SaveSyncService. Methods: pushSaves(), pullSave(), getStrategyForSlug(). Wires all strategies to RommService. Exposes windowsSaveStrategy for external access. -- `lib/core/save/strategies/retroarch_save_strategy.dart` — RetroArch save strategy (GBA/GBC/GB/SNES/NES/N64/NDS/PSX/PSP/Dreamcast/Megadrive). Reads saves/{coreName}/{stem}.srm and .state.auto. -- `lib/core/save/strategies/dolphin_save_strategy.dart` — Dolphin save strategy (GC/Wii). Reads User/GC/{region}/Card A/*.gci. -- `lib/core/save/strategies/eden_save_strategy.dart` — Eden/Switch save strategy. Resolves title ID via filename regex, XCI header parse, or save folder scan. Zips save folder for upload. -- `lib/core/save/strategies/windows_save_strategy.dart` — Windows native game save strategy. Uses PCGamingWiki API for auto-detection, supports manual override. Zips entire save directory for upload, extracts on restore. Persists overrides via SharedPreferences (prefix `win_save_`). -- `lib/core/save/strategies/pcsx2_save_strategy.dart` — PCSX2 save strategy (PS2). Memcards: {emulatorDir}/memcards/Mcd001.ps2, Mcd002.ps2. States: {emulatorDir}/sstates/{stem}.000 etc. -- `lib/core/save/strategies/rpcs3_save_strategy.dart` — RPCS3 save strategy (PS3). Saves at %APPDATA%\rpcs3\dev_hdd0\home00000001円\savedata\{titleId}\. Extracts title ID via regex [A-Z]{4}\d{5}. Zips save folder for upload. Uses Process.run for APPDATA resolution. -- `lib/core/save/strategies/xenia_save_strategy.dart` — Xenia Canary save strategy (Xbox 360). Saves at {emulatorDir}\content\{titleId}00000001円\. Extracts title ID via 8-char hex regex. Zips save folder for upload. - -### Core — Emulator -- `lib/core/emulator/emulator_strategy.dart` — Abstract base class. Fields: name, emulatorId, supportedSlugs, windowsExecutable, linuxExecutable. Methods: launch(Game, romPath), resolveSavePath(Game), getExecutableForPlatform(). -- `lib/core/emulator/emulator_registry_data.dart` — Static data for emulator definitions. -- `lib/core/emulator/strategy_registry.dart` — Registry for emulator strategies. Methods: getStrategyForSlug(), getDefinition(). -- `lib/core/emulator/emulator_download_service.dart` — Service for downloading and extracting emulators. Supports direct URL and GitHub release types. Handles .zip and .7z extraction. -- `lib/core/emulator/github_release_service.dart` — Fetches latest release asset URL from GitHub API with required/excluded name filters. -- `lib/core/emulator/strategies/retroarch_strategy.dart` — RetroArch strategy. Slugs: gba/gbc/gb/nes/snes/n64/nds/psx/ps1/psp/dc/dreamcast/megadrive/genesis/md etc. -- `lib/core/emulator/strategies/dolphin_strategy.dart` — Dolphin strategy. Slugs: gc/gamecube/wii/ngc. -- `lib/core/emulator/strategies/eden_strategy.dart` — Eden strategy. Slugs: switch/nintendo-switch/ns. -- `lib/core/emulator/strategies/rpcs3_strategy.dart` — RPCS3 strategy. Slugs: ps3/playstation-3/playstation3. -- `lib/core/emulator/strategies/pcsx2_strategy.dart` — PCSX2 strategy. Slugs: ps2/playstation-2/playstation2. -- `lib/core/emulator/strategies/azahar_strategy.dart` — Azahar strategy. Slugs: 3ds/n3ds/nintendo-3ds/nintendo3ds/new-nintendo-3ds/new-nintendo-3ds-xl. -- `lib/core/emulator/strategies/cemu_strategy.dart` — Cemu strategy. Slugs: wiiu/wii-u/nintendo-wii-u/nintendo-wiiu. -- `lib/core/emulator/strategies/duckstation_strategy.dart` — DuckStation strategy. Slugs: ps1/playstation/psx. -- `lib/core/emulator/strategies/xemu_strategy.dart` — Xemu strategy. Slugs: xbox. -- `lib/core/emulator/strategies/xenia_strategy.dart` — Xenia Canary strategy. Slugs: xbox360/xbla. -- `lib/core/emulator/strategies/windows_strategy.dart` — Windows native game strategy. Auto-detects exe in game folder, validates stored override exists on disk before using. Launches via Process.start. Monitors exit code for 5s — throws if crashed. Persists overrides via SharedPreferences (prefix `win_exe_`). - -### Core — Downloader -- `lib/core/downloader/download_service.dart` — HTTP ROM download via Dio. Stream<DownloadProgress> for UI. Handles .zip extraction via archive package and .7z via bundled 7zr.exe (resolved via DirectoryService.resolveSevenZipPath()). Windows games (.zip/.7z) always extracted regardless of isMultiFile flag. - -### Core — Storage -- `lib/core/storage/directory_service.dart` — Manages ROMs and emulator directories. Persists paths via SharedPreferences. resolveSevenZipPath() extracts bundled 7zr.exe from Flutter assets to %APPDATA%\Freegosy\thirdparty\ on first run. Uses defaultTargetPlatform for Windows check, Process.run for APPDATA resolution. - -### Core — Windows -- `lib/core/windows/windows_game_service.dart` — Finds main exe in game folder (hint match then largest). Skips uninstall/setup/redist/etc. Launches via Process.start detached. -- `lib/core/windows/pcgamingwiki_service.dart` — Queries PCGamingWiki API for Windows game save locations. Parses MediaWiki markup, expands environment variables (APPDATA, LOCALAPPDATA etc), returns resolved paths. - -### Core — Updater -- `lib/core/updater/updater_service.dart` — Checks GitHub Releases API for new version. Downloads new binary to temp, swaps, relaunches. - -### Providers -- `lib/providers/romm_provider.dart` — Riverpod providers for RomM config, connection state, DirectoryService, StrategyRegistry (loads WindowsStrategy persisted overrides on init), SaveSyncService (loads WindowsSaveStrategy persisted overrides on init). -- `lib/providers/library_provider.dart` — Riverpod providers for platforms list and games list. Includes search, filtering, card aspect ratio, and RetroArch sync mode persistence. -- `lib/providers/download_provider.dart` — Riverpod providers for active downloads and progress. - -### UI — Screens -- `lib/ui/screens/library_screen.dart` — Main screen. Game grid with search, platform filter, download, launch, save sync. Windows games support long-press to open config dialog and auto-show config on missing exe. Launch errors show for 8 seconds. -- `lib/ui/screens/download_screen.dart` — Active downloads list with progress bars. -- `lib/ui/screens/settings_screen.dart` — RomM server config, card aspect ratio, storage paths, RetroArch sync mode, emulator download/install status. - -### UI — Widgets -- `lib/ui/widgets/game_card.dart` — Single game tile. Shows cover, name, download/launch/sync buttons. Green dot when downloaded. -- `lib/ui/widgets/download_progress_card.dart` — Single download row with progress bar and cancel button. -- `lib/ui/widgets/platform_filter_bar.dart` — Horizontal scrollable platform chip row with distinct styling for selected/unselected states. -- `lib/ui/widgets/windows_game_config_dialog.dart` — Dialog for configuring Windows game exe path and save directory. Browse buttons for both. Returns `Map<String, String>` with keys `exe` and `save`. Only shown for Windows platform games. - -## Key Contracts - -### EmulatorStrategy (never change these signatures) -```dart -abstract class EmulatorStrategy { - String get name; - String get emulatorId; - List<String> get supportedSlugs; - String get windowsExecutable; - String get linuxExecutable; - String getExecutableForPlatform(); - Future<void> launch(Game game, String romPath); - String resolveSavePath(Game game); - bool get supportsSaveSync; -} -``` - -### DownloadProgress -```dart -class DownloadProgress { - final String id; // e.g., game ID or emulator ID - final String gameName; // display name (game.name or emulator name) - final double percent; - final int bytesReceived; - final int totalBytes; - final bool isComplete; - final String? error; -} -``` - -### RomMConfig -```dart -class RomMConfig { - final String baseUrl; - final String username; - final String password; -} -``` - -## Dependencies (pubspec.yaml) -- `flutter_riverpod` — state management -- `dio` — HTTP client for API calls and downloads -- `path_provider` — platform-safe file paths -- `shared_preferences` — persist RomM config, card ratio, sync mode, Windows exe/save overrides -- `package_info_plus` — read current app version for updater -- `archive` — zip extraction and creation (ZipDecoder, ZipFileEncoder) -- `file_picker` — directory and file selection -- `path` — path manipulation utilities +# Freegosy — Agent Map +> This file is the source of truth for all LLM agents (Claude, Gemini) working on this codebase. +> Read this before touching any file. Update this file if you create or split any file. + +## Project Overview +Freegosy is a cross-platform Flutter app for browsing a RomM library, downloading ROMs via HTTP, and launching emulators. Built with Riverpod for state management. + +## Rules (MANDATORY) +- No file exceeds 600 lines. If adding code would exceed this, split the file first and update this map. +- All RomM API calls go through `romm_service.dart` only. Never call the API directly from UI or providers. +- All emulator logic goes through the strategy pattern. Never hardcode emulator behavior in UI. +- New emulator = new file in `core/emulator/strategies/`, register in `strategy_registry.dart` only. +- New save strategy = new file in `core/save/strategies/`, register in `save_sync_service.dart` only. +- New screen = new file in `ui/screens/`. +- New reusable widget = new file in `ui/widgets/`. +- Providers are thin — they call services, they do not contain business logic. +- Never use `Platform.environment` or `Platform.isWindows` directly — causes conflicts with flutter/foundation.dart. Use `Process.run('cmd', ['/c', 'echo %APPDATA%'])` for env vars, and `defaultTargetPlatform == TargetPlatform.windows` for platform checks. + +## File Map + +### Entry Points +- `lib/main.dart` — App entry point. Initializes Riverpod ProviderScope. Calls app.dart. +- `lib/app.dart` — MaterialApp setup, theme, initial route, navigation shell. + +### Core — RomM +- `lib/core/romm/romm_service.dart` — All RomM HTTP calls (Dio). Methods: getPlatforms(), getGames(), getSaves(), uploadSave(), getLatestSave(), downloadSave(). Returns typed models. +- `lib/core/romm/romm_models.dart` — Data models: Game, Platform, SaveFile, RomMConfig. + +### Core — Save Sync +- `lib/core/save/save_strategy.dart` — Abstract base class SaveStrategy. Methods: getSaveDir(), getSaveFiles(), restoreSave(). Helpers: backupSave() keeps max 3 clean versions (.bak, .bak1, .bak2) — never creates chained .bak.bak files., getRomStem(). +- `lib/core/save/save_sync_service.dart` — SaveSyncService. Methods: pushSaves(), pullSave(), getStrategyForSlug(). getStrategyForSlug() checks StrategyRegistry user preferences first before falling back to platform slug defaults. Accepts StrategyRegistry as constructor parameter. Wires all strategies to RommService. Exposes windowsSaveStrategy for external access. +- `lib/core/save/strategies/retroarch_save_strategy.dart` — RetroArch save strategy (GBA/GBC/GB/SNES/NES/N64/NDS/PSX/PSP/Dreamcast/Megadrive). Reads saves/{coreName}/{stem}.srm and .state.auto. PSP special case returns entire saves/PPSSPP/PSP directory as zip. All platforms use dual-stem (getRomStem + romPath filename) for state file matching. Directory existence check handles both File and Directory types. +- `lib/core/save/strategies/dolphin_save_strategy.dart` — Dolphin save strategy (GC/Wii). Reads User/GC/{region}/Card A/*.gci. +- `lib/core/save/strategies/eden_save_strategy.dart` — Eden/Switch save strategy. Resolves title ID via filename regex, XCI header parse, or save folder scan. Zips save folder for upload. +- `lib/core/save/strategies/windows_save_strategy.dart` — Windows native game save strategy. Uses PCGamingWiki API for auto-detection, supports manual override. Zips entire save directory for upload, extracts on restore. Persists overrides via SharedPreferences (prefix `win_save_`). +- `lib/core/save/strategies/pcsx2_save_strategy.dart` — PCSX2 save strategy (PS2). Memcards: {emulatorDir}/memcards/Mcd001.ps2, Mcd002.ps2. States: {emulatorDir}/sstates/{stem}.000 etc. +- `lib/core/save/strategies/rpcs3_save_strategy.dart` — RPCS3 save strategy (PS3). Saves at %APPDATA% pcs3\dev_hdd0\home00000001円\savedata\{titleId}\. Extracts title ID via regex [A-Z]{4}\d{5}. Zips save folder for upload. Uses Process.run for APPDATA resolution. +- `lib/core/save/strategies/xenia_save_strategy.dart` — Xenia Canary save strategy (Xbox 360). Saves at {emulatorDir}\content\{titleId}00000001円\. Extracts title ID via 8-char hex regex. Zips save folder for upload. +- `lib/core/save/strategies/duckstation_save_strategy.dart` — DuckStation save strategy (PS1). Checks for portable.txt in emulator dir — if present uses {emulatorDir}/memcards/{stem}.mcd, otherwise falls back to %LOCALAPPDATA%\DuckStation\memcards\{stem}.mcd. +- `lib/core/save/strategies/melonds_save_strategy.dart` — melonDS save strategy (NDS). Saves .sav file next to ROM, derived from actual romPath filename not game name. +- `lib/core/save/strategies/mgba_save_strategy.dart` — mGBA save strategy (GBA/GBC/GB). Saves .sav file next to ROM, derived from actual romPath filename. +- `lib/core/save/strategies/ppsspp_save_strategy.dart` — PPSSPP save strategy (PSP). Saves at {emulatorDir}/memstick/PSP/SAVEDATA/, states at PPSSPP_STATE/. Returns entire PSP/SAVEDATA directory as zip. Restore strips top-level SAVEDATA folder to extract directly into memstick/PSP/SAVEDATA/. +- `lib/core/save/strategies/cemu_save_strategy.dart` — Cemu save strategy (Wii U). Zips {emulatorDir}/mlc01/usr/save/00050000/ for upload. Restore extracts zip directly into mlc01/usr/save/ skipping .bak entries. + +### Core — Emulator +- `lib/core/emulator/emulator_strategy.dart` — Abstract base class. Fields: name, emulatorId, supportedSlugs, windowsExecutable, linuxExecutable. Methods: launch(Game, romPath), resolveSavePath(Game), getExecutableForPlatform(). Optional launchWithHandle(Game, romPath) method returns Process handle for auto-sync tracking. Default returns null. +- `lib/core/emulator/emulator_registry_data.dart` — Static data for emulator definitions. Includes RetroArch, Dolphin, Eden, RPCS3, PCSX2, Azahar, Cemu, Xemu, Xenia, DuckStation, Flycast, melonDS, PPSSPP, mGBA, MAME. +- `lib/core/emulator/strategy_registry.dart` — Registry for emulator strategies. Includes conflict detection via detectConflicts() returning Map<String, List<EmulatorStrategy>> grouped by canonical platform name. setPreference(slug, emulatorId) and loadPreferences() persist user preferences under SharedPreferences key prefix 'emulator_pref_'. loadPreferences() is called on startup in romm_provider.dart to ensure conflict preferences are available before first launch. +- `lib/core/emulator/emulator_download_service.dart` — Service for downloading emulators. Supports direct URL and GitHub release types. Uses ExtractionService for all extraction. +- `lib/core/emulator/github_release_service.dart` — Fetches latest release asset URL from GitHub API with required/excluded name filters. +- `lib/core/emulator/strategies/retroarch_strategy.dart` — RetroArch strategy. Slugs: gba/gbc/gb/nes/snes/n64/nds/psx/ps1/psp/dc/dreamcast/megadrive/genesis/md etc. Throws MissingRetroArchCoreException when a core .dll is missing. Has downloadCore() method to fetch from RetroArch buildbot. +- `lib/core/emulator/strategies/dolphin_strategy.dart` — Dolphin strategy. Slugs: gc/gamecube/wii/ngc. +- `lib/core/emulator/strategies/eden_strategy.dart` — Eden strategy. Slugs: switch/nintendo-switch/ns. +- `lib/core/emulator/strategies/rpcs3_strategy.dart` — RPCS3 strategy. Slugs: ps3/playstation-3/playstation3. +- `lib/core/emulator/strategies/pcsx2_strategy.dart` — PCSX2 strategy. Slugs: ps2/playstation-2/playstation2. +- `lib/core/emulator/strategies/azahar_strategy.dart` — Azahar strategy. Slugs: 3ds/n3ds/nintendo-3ds/nintendo3ds/new-nintendo-3ds/new-nintendo-3ds-xl. +- `lib/core/emulator/strategies/cemu_strategy.dart` — Cemu strategy. Slugs: wiiu/wii-u/nintendo-wii-u/nintendo-wiiu. +- `lib/core/emulator/strategies/duckstation_strategy.dart` — DuckStation strategy. Slugs: ps1/playstation/psx. +- `lib/core/emulator/strategies/flycast_strategy.dart` — Flycast strategy. Slugs: dc/dreamcast/naomi/naomi2/atomiswave/cave/hikaru. +- `lib/core/emulator/strategies/melonds_strategy.dart` — melonDS strategy. Slugs: nds/nintendo-ds/ds. +- `lib/core/emulator/strategies/ppsspp_strategy.dart` — PPSSPP strategy. Slugs: psp/playstation-portable. Implements launchWithHandle() returning Process with ProcessStartMode.normal for auto-sync on game close. +- `lib/core/emulator/strategies/mgba_strategy.dart` — mGBA strategy. Slugs: gba/gbc/gb/game-boy-advance/game-boy-color/game-boy. +- `lib/core/emulator/strategies/mame_strategy.dart` — MAME strategy. Slugs: arcade/mame. Handles self-extracting .exe downloads. +- `lib/core/emulator/strategies/xemu_strategy.dart` — Xemu strategy. Slugs: xbox. +- `lib/core/emulator/strategies/xenia_strategy.dart` — Xenia Canary strategy. Slugs: xbox360/xbla. +- `lib/core/emulator/strategies/windows_strategy.dart` — Windows native game strategy. Auto-detects exe in game folder, validates stored override exists on disk before using. Launches via Process.start. Monitors exit code for 5s — throws if crashed. Persists overrides via SharedPreferences (prefix `win_exe_`). + +### Core — Extraction +- `lib/core/extraction/extraction_service.dart` — Unified extraction service. Handles .zip via archive package, .7z via bundled 7zr.exe, and self-extracting .exe files. + +### Core — Downloader +- `lib/core/downloader/download_service.dart` — HTTP ROM download via Dio. Stream<DownloadProgress> for UI. Uses ExtractionService for all extraction. + +### Core — Storage +- `lib/core/storage/directory_service.dart` — Manages ROMs and emulator directories. Persists paths via SharedPreferences. setEmulatorPathOverride()/getEmulatorPathOverride()/loadEmulatorPathOverrides() allow users to point any emulator to a custom installation directory, persisted under SharedPreferences key prefix 'emu_path_'. resolveSevenZipPath() extracts bundled 7zr.exe from Flutter assets. + +### Core — Windows +- `lib/core/windows/windows_game_service.dart` — Finds main exe in game folder. Launches via Process.start detached. +- `lib/core/windows/pcgamingwiki_service.dart` — Queries PCGamingWiki API for Windows game save locations. + +### Core — Updater +- `lib/core/updater/updater_service.dart` — Checks GitHub Releases API for new version. Downloads and relaunch. + +### Providers +- `lib/providers/romm_provider.dart` — Riverpod providers for RomM config, connection state, DirectoryService, StrategyRegistry, SaveSyncService. strategyRegistryProvider and saveSyncServiceProvider are FutureProviders that must be awaited with .future to ensure preferences are loaded before use. +- `lib/providers/library_provider.dart` — Riverpod providers for platforms and games. Search, filtering, and display settings persistence. Background refresh silently updates cache. +- `lib/providers/download_provider.dart` — Riverpod providers for active downloads (games and emulators) and progress. + +### UI — Screens +- `lib/ui/screens/library_screen.dart` — Main screen. Game grid with search, platform filter, preset display system. Launch flow shows status snackbars at each stage. Catches MissingRetroArchCoreException to offer auto-download of missing core then re-launches. Cache invalidation on pull-to-refresh. Auto-syncs saves when game process exits via launchWithHandle(). Awaits strategyRegistryProvider.future and saveSyncServiceProvider.future on launch to ensure preferences are loaded before strategy lookup. +- `lib/ui/screens/library_skeleton.dart` — Skeleton loading grid and _SkeletonCard widget. Contains top-level functions buildSkeletonGrid() and calculateCardHeight(). +- `lib/ui/screens/download_screen.dart` — Active downloads list. +- `lib/ui/screens/settings_screen.dart` — Server config, storage paths, RetroArch sync mode. +- `lib/ui/screens/settings_display_section.dart` — Extracted display settings section. Contains buildDisplaySection() function with preset chips, column count, card shape, spacing, title and hover toggles. +- `lib/ui/screens/settings_emulators_section.dart` — Extracted emulators section. Contains buildEmulatorsSection() and buildConflictsSection() functions. Handles emulator download, custom path overrides, and conflict resolution UI. + +### UI — Widgets +- `lib/ui/widgets/game_card.dart` — StatefulWidget (not Consumer). Accepts pre-resolved coverUrl. Uses CachedNetworkImage (memCache: 300x400). +- `lib/ui/widgets/download_progress_card.dart` — Single download row with progress bar. +- `lib/ui/widgets/platform_filter_bar.dart` — Platform chip row. +- `lib/ui/widgets/windows_game_config_dialog.dart` — Dialog for configuring Windows game overrides. + +## Key Contracts + +### EmulatorStrategy (never change these signatures) +```dart +abstract class EmulatorStrategy { + String get name; + String get emulatorId; + List<String> get supportedSlugs; + String get windowsExecutable; + String get linuxExecutable; + String getExecutableForPlatform(); + Future<void> launch(Game game, String romPath); + String resolveSavePath(Game game); + bool get supportsSaveSync; +} +``` + +### DownloadProgress +```dart +class DownloadProgress { + final String id; // e.g., game ID or emulator ID + final String gameName; // display name (game.name or emulator name) + final double percent; + final int bytesReceived; + final int totalBytes; + final bool isComplete; + final String? error; +} +``` + +### RomMConfig +```dart +class RomMConfig { + final String baseUrl; + final String username; + final String password; +} +``` + +## Dependencies (pubspec.yaml) +- `flutter_riverpod` — state management +- `dio` — HTTP client for API calls and downloads +- `path_provider` — platform-safe file paths +- `shared_preferences` — persist RomM config, display settings, overrides +- `package_info_plus` — read current app version +- `archive` — zip extraction and creation +- `cached_network_image` — disk and memory cache for network images +- `file_picker` — directory and file selection +- `path` — path manipulation utilities - `thirdparty/7zr.exe` — bundled 7-Zip console executable for .7z extraction (Flutter asset) \ No newline at end of file diff --git a/lib/core/downloader/download_service.dart b/lib/core/downloader/download_service.dart index e83fab2..ee7cce4 100644 --- a/lib/core/downloader/download_service.dart +++ b/lib/core/downloader/download_service.dart @@ -1,8 +1,8 @@ import 'package:dio/dio.dart'; import 'dart:async'; import 'dart:io'; -import 'package:archive/archive_io.dart'; import 'package:freegosy/core/storage/directory_service.dart'; +import 'package:freegosy/core/extraction/extraction_service.dart'; import 'package:freegosy/core/romm/romm_models.dart'; class DownloadProgress { @@ -30,8 +30,13 @@ class DownloadProgress { class DownloadService { final Dio dio; final DirectoryService directoryService; + final ExtractionService extractionService; - DownloadService({required this.dio, required this.directoryService}); + DownloadService({ + required this.dio, + required this.directoryService, + required this.extractionService, + }); Stream<DownloadProgress> download(Game game, String downloadUrl, {Map<String, String>? headers}) async* { if (await directoryService.isRomDownloaded(game)) { @@ -67,7 +72,8 @@ class DownloadService { final isWindowsGame = ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? ''); final isArchive = savePath.toLowerCase().endsWith('.zip') || savePath.toLowerCase().endsWith('.7z'); - if (game.isMultiFile || (isWindowsGame && isArchive)) { + final shouldExtract = game.isMultiFile || (isWindowsGame && isArchive); + if (shouldExtract) { controller.add(DownloadProgress( id: game.id, gameName: game.name, @@ -114,25 +120,16 @@ class DownloadService { await Directory(extractDir).create(recursive: true); - if (zipPath.toLowerCase().endsWith('.7z')) { - final sevenZipExe = await directoryService.resolveSevenZipPath(); - if (sevenZipExe == null) { - throw Exception('7zr.exe could not be initialized. Try reinstalling Freegosy.'); - } - final result = await Process.run( - sevenZipExe, ['x', zipPath, '-o$extractDir', '-y'], - runInShell: false, - ); - if (result.exitCode != 0) { - throw Exception('7z extraction failed: ${result.stderr}'); + try { + await extractionService.extract(zipPath, extractDir); + await File(zipPath).delete(); + } catch (e) { + if (e.toString().contains('Unsupported archive format')) { + // File is not an archive - leave it as downloaded + await Directory(extractDir).delete(); + return; } - } else { - final bytes = await File(zipPath).readAsBytes(); - final archive = ZipDecoder().decodeBytes(bytes); - extractArchiveToDisk(archive, extractDir); + rethrow; } - - // Delete the archive after extraction - await File(zipPath).delete(); } } \ No newline at end of file diff --git a/lib/core/emulator/emulator_download_service.dart b/lib/core/emulator/emulator_download_service.dart index 979da86..968a204 100644 --- a/lib/core/emulator/emulator_download_service.dart +++ b/lib/core/emulator/emulator_download_service.dart @@ -3,18 +3,19 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; -import 'package:archive/archive_io.dart'; import '../downloader/download_service.dart'; import '../storage/directory_service.dart'; +import '../extraction/extraction_service.dart'; import 'emulator_registry_data.dart'; import 'github_release_service.dart'; class EmulatorDownloadService { final Dio _dio; final DirectoryService _directoryService; + final ExtractionService _extractionService; late final GithubReleaseService _githubService; - EmulatorDownloadService(this._dio, this._directoryService) { + EmulatorDownloadService(this._dio, this._directoryService, this._extractionService) { _githubService = GithubReleaseService(_dio); } @@ -70,7 +71,7 @@ class EmulatorDownloadService { yield DownloadProgress( id: emulatorId, gameName: emulatorName, - error: 'No download URL for this platform', + error: 'This emulator is not available for your platform', ); return; } @@ -107,7 +108,7 @@ class EmulatorDownloadService { percent: 1.0, status: 'Extracting...', )); - await _extractArchive(tempFilePath, emulatorDir); + await _extractionService.extract(tempFilePath, emulatorDir); controller.add(DownloadProgress( id: emulatorId, gameName: emulatorName, @@ -144,22 +145,4 @@ class EmulatorDownloadService { ); } } - - Future<void> _extractArchive(String archivePath, String destDir) async { - if (archivePath.endsWith('.zip')) { - final bytes = await File(archivePath).readAsBytes(); - final archive = ZipDecoder().decodeBytes(bytes); - extractArchiveToDisk(archive, destDir); - } else if (archivePath.endsWith('.7z')) { - final result = await Process.run( - '7z', ['x', archivePath, '-o$destDir', '-y'], - runInShell: true, - ); - if (result.exitCode != 0) { - throw Exception('7z extraction failed: ${result.stderr}'); - } - } else { - throw Exception('Unsupported archive format: $archivePath'); - } - } } \ No newline at end of file diff --git a/lib/core/emulator/emulator_registry_data.dart b/lib/core/emulator/emulator_registry_data.dart index 0aefadf..54ba544 100644 --- a/lib/core/emulator/emulator_registry_data.dart +++ b/lib/core/emulator/emulator_registry_data.dart @@ -12,6 +12,7 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'gamegear', 'atari2600', 'atari7800', 'lynx', 'neogeo', 'arcade', 'mame', 'pcengine', 'wonderswan', 'virtualboy', 'msx', 'dos' ], + 'supported_platforms': ['windows', 'linux'], }, { 'id': 'dolphin', @@ -21,6 +22,7 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'windows_executable': 'Dolphin.exe', 'linux_executable': 'dolphin-emu', 'platform_slugs': ['gc', 'gamecube', 'wii', 'ngc'], + 'supported_platforms': ['windows', 'linux'], }, { 'id': 'eden', @@ -30,6 +32,7 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'windows_executable': 'eden.exe', 'linux_executable': 'eden', 'platform_slugs': ['switch', 'nintendo-switch', 'ns'], + 'supported_platforms': ['windows', 'linux'], }, { 'id': 'rpcs3', @@ -41,6 +44,7 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'windows_executable': 'rpcs3.exe', 'linux_executable': 'rpcs3', 'platform_slugs': ['ps3', 'playstation-3', 'playstation3'], + 'supported_platforms': ['windows', 'linux'], }, { 'id': 'pcsx2', @@ -52,6 +56,7 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'windows_executable': 'pcsx2-qt.exe', 'linux_executable': 'pcsx2-qt', 'platform_slugs': ['ps2', 'playstation-2', 'playstation2'], + 'supported_platforms': ['windows', 'linux'], }, { 'id': 'azahar', @@ -63,17 +68,19 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'windows_executable': 'azahar.exe', 'linux_executable': 'azahar', 'platform_slugs': ['3ds', 'n3ds', 'nintendo-3ds', 'nintendo3ds', 'new-nintendo-3ds', 'new-nintendo-3ds-xl'], + 'supported_platforms': ['windows', 'linux'], }, { 'id': 'cemu', 'name': 'Cemu (Wii U)', 'type': 'github', 'github_repo': 'cemu-project/Cemu', - 'github_asset_required': ['windows'], - 'github_asset_excluded': ['experimental', 'debug'], + 'github_asset_required': ['windows', 'x64', '.zip'], + 'github_asset_excluded': ['debug', 'symbols', 'linux', 'macos'], 'windows_executable': 'Cemu.exe', 'linux_executable': 'cemu', 'platform_slugs': ['wiiu', 'wii-u', 'nintendo-wii-u', 'nintendo-wiiu'], + 'supported_platforms': ['windows', 'linux'], }, { 'id': 'xemu', @@ -85,6 +92,7 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'windows_executable': 'xemu.exe', 'linux_executable': 'xemu', 'platform_slugs': ['xbox'], + 'supported_platforms': ['windows'], }, { 'id': 'xenia_canary', @@ -96,6 +104,7 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'windows_executable': 'xenia_canary.exe', 'linux_executable': 'xenia_canary', 'platform_slugs': ['xbox360', 'xbla'], + 'supported_platforms': ['windows'], }, { 'id': 'duckstation', @@ -107,6 +116,68 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'windows_executable': 'duckstation-qt-x64-ReleaseLTCG.exe', 'linux_executable': 'duckstation-qt', 'platform_slugs': ['ps1', 'playstation', 'psx'], + 'supported_platforms': ['windows', 'linux'], + }, + { + 'id': 'flycast', + 'name': 'Flycast (Dreamcast)', + 'type': 'github', + 'github_repo': 'flyinghead/flycast', + 'github_asset_required': ['win64', '.zip'], + 'github_asset_excluded': ['debug', 'symbols', 'linux', 'macos'], + 'windows_executable': 'flycast.exe', + 'linux_executable': 'flycast', + 'platform_slugs': ['dc', 'dreamcast', 'naomi', 'naomi2', 'atomiswave', 'cave', 'hikaru'], + 'supported_platforms': ['windows', 'linux'], + }, + { + 'id': 'melonds', + 'name': 'melonDS', + 'type': 'github', + 'github_repo': 'melonDS-emu/melonDS', + 'github_asset_required': ['win', '.zip'], + 'github_asset_excluded': ['source', 'debug'], + 'windows_executable': 'melonDS.exe', + 'linux_executable': 'melonDS', + 'platform_slugs': ['nds', 'nintendo-ds', 'ds'], + 'supported_platforms': ['windows', 'linux'], + }, + { + 'id': 'ppsspp', + 'name': 'PPSSPP (PSP)', + 'type': 'github', + 'github_repo': 'hrydgard/ppsspp', + 'github_asset_required': ['Windows', 'x64', '.zip'], + 'github_asset_excluded': ['debug', 'symbols', 'VR'], + 'windows_executable': 'PPSSPPWindows64.exe', + 'linux_executable': 'PPSSPP', + 'supported_platforms': ['windows', 'linux'], + 'platform_slugs': ['psp', 'playstation-portable'], + }, + { + 'id': 'mgba', + 'name': 'mGBA (GBA)', + 'type': 'github', + 'github_repo': 'mgba-emu/mgba', + 'github_asset_required': ['win64', '.7z'], + 'github_asset_excluded': ['debug', 'symbols', 'source'], + 'windows_executable': 'mGBA.exe', + 'linux_executable': 'mgba', + 'supported_platforms': ['windows', 'linux'], + 'platform_slugs': ['gba', 'gbc', 'gb', 'game-boy-advance', 'game-boy-color', 'game-boy'], + }, + { + 'id': 'mame', + 'name': 'MAME', + 'type': 'github', + 'github_repo': 'mamedev/mame', + 'github_asset_required': ['x64.exe'], + 'github_asset_excluded': ['debug', 'symbols', 'sources', 'tools'], + 'extraction_type': 'self_extracting', + 'windows_executable': 'mame.exe', + 'linux_executable': 'mame', + 'supported_platforms': ['windows', 'linux'], + 'platform_slugs': ['arcade', 'mame'], }, { 'id': 'windows_native', @@ -115,5 +186,6 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'windows_executable': '', 'linux_executable': '', 'platform_slugs': ['windows', 'pc', 'win'], + 'supported_platforms': ['windows'], }, -]; \ No newline at end of file +]; diff --git a/lib/core/emulator/emulator_strategy.dart b/lib/core/emulator/emulator_strategy.dart index 3a28d16..b0e70f9 100644 --- a/lib/core/emulator/emulator_strategy.dart +++ b/lib/core/emulator/emulator_strategy.dart @@ -1,4 +1,5 @@ -import 'dart:io' as io; +import 'dart:io'; +import 'dart:io' as io; import 'package:freegosy/core/romm/romm_models.dart'; abstract class EmulatorStrategy { @@ -16,5 +17,6 @@ abstract class EmulatorStrategy { } Future<void> launch(Game game, String romPath); + Future<Process?> launchWithHandle(Game game, String romPath) async => null; String resolveSavePath(Game game); } diff --git a/lib/core/emulator/strategies/azahar_strategy.dart b/lib/core/emulator/strategies/azahar_strategy.dart index 9e7b673..2322d9f 100644 --- a/lib/core/emulator/strategies/azahar_strategy.dart +++ b/lib/core/emulator/strategies/azahar_strategy.dart @@ -38,6 +38,15 @@ class AzaharStrategy extends EmulatorStrategy { await Process.start(exePath, [romPath], mode: ProcessStartMode.detached); } + @override + Future<Process?> launchWithHandle(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable( + emulatorId, getExecutableForPlatform(), + ); + if (exePath == null) throw Exception('$name not found. Please download it first.'); + return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal); + } + @override String resolveSavePath(Game game) => ''; } \ No newline at end of file diff --git a/lib/core/emulator/strategies/cemu_strategy.dart b/lib/core/emulator/strategies/cemu_strategy.dart index 60732ea..326d2e4 100644 --- a/lib/core/emulator/strategies/cemu_strategy.dart +++ b/lib/core/emulator/strategies/cemu_strategy.dart @@ -35,6 +35,15 @@ class CemuStrategy extends EmulatorStrategy { await Process.start(exePath, ['-g', romPath], mode: ProcessStartMode.detached); } + @override + Future<Process?> launchWithHandle(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable( + emulatorId, getExecutableForPlatform(), + ); + if (exePath == null) throw Exception('$name not found. Please download it first.'); + return await Process.start(exePath, ['-g', romPath], mode: ProcessStartMode.normal); + } + @override String resolveSavePath(Game game) => ''; } \ No newline at end of file diff --git a/lib/core/emulator/strategies/dolphin_strategy.dart b/lib/core/emulator/strategies/dolphin_strategy.dart index 274c301..a1a8b40 100644 --- a/lib/core/emulator/strategies/dolphin_strategy.dart +++ b/lib/core/emulator/strategies/dolphin_strategy.dart @@ -33,6 +33,13 @@ class DolphinStrategy extends EmulatorStrategy { await Process.run(exePath, ['-e', romPath]); } + @override + Future<Process?> launchWithHandle(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable(emulatorId, getExecutableForPlatform()); + if (exePath == null) throw Exception('$name not found. Please download it first.'); + return await Process.start(exePath, ['-e', romPath], mode: ProcessStartMode.normal); + } + @override String resolveSavePath(Game game) { return ''; // Placeholder diff --git a/lib/core/emulator/strategies/duckstation_strategy.dart b/lib/core/emulator/strategies/duckstation_strategy.dart index 74f2663..f098b2e 100644 --- a/lib/core/emulator/strategies/duckstation_strategy.dart +++ b/lib/core/emulator/strategies/duckstation_strategy.dart @@ -35,6 +35,15 @@ class DuckstationStrategy extends EmulatorStrategy { await Process.start(exePath, ['-batch', romPath], mode: ProcessStartMode.detached); } + @override + Future<Process?> launchWithHandle(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable( + emulatorId, getExecutableForPlatform(), + ); + if (exePath == null) throw Exception('$name not found. Please download it first.'); + return await Process.start(exePath, ['-batch', romPath], mode: ProcessStartMode.normal); + } + @override String resolveSavePath(Game game) => ''; } \ No newline at end of file diff --git a/lib/core/emulator/strategies/eden_strategy.dart b/lib/core/emulator/strategies/eden_strategy.dart index 93c16b1..d72d953 100644 --- a/lib/core/emulator/strategies/eden_strategy.dart +++ b/lib/core/emulator/strategies/eden_strategy.dart @@ -33,6 +33,13 @@ class EdenStrategy extends EmulatorStrategy { await Process.run(exePath, [romPath]); } + @override + Future<Process?> launchWithHandle(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable(emulatorId, getExecutableForPlatform()); + if (exePath == null) throw Exception('$name not found. Please download it first.'); + return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal); + } + @override String resolveSavePath(Game game) { return ''; // Placeholder diff --git a/lib/core/emulator/strategies/flycast_strategy.dart b/lib/core/emulator/strategies/flycast_strategy.dart new file mode 100644 index 0000000..6606830 --- /dev/null +++ b/lib/core/emulator/strategies/flycast_strategy.dart @@ -0,0 +1,49 @@ +import 'dart:io'; +import 'package:freegosy/core/emulator/emulator_strategy.dart'; +import 'package:freegosy/core/romm/romm_models.dart'; +import 'package:freegosy/core/storage/directory_service.dart'; + +class FlycastStrategy extends EmulatorStrategy { + final DirectoryService _directoryService; + + FlycastStrategy(this._directoryService); + + @override + String get name => 'Flycast'; + + @override + String get emulatorId => 'flycast'; + + @override + List<String> get supportedSlugs => ['dc', 'dreamcast', 'naomi', 'atomiswave']; + + @override + String get windowsExecutable => 'flycast.exe'; + + @override + String get linuxExecutable => 'flycast'; + + @override + bool get supportsSaveSync => false; + + @override + Future<void> launch(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable( + emulatorId, getExecutableForPlatform(), + ); + if (exePath == null) throw Exception('$name not found. Please download it first.'); + await Process.start(exePath, [romPath], mode: ProcessStartMode.detached); + } + + @override + Future<Process?> launchWithHandle(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable( + emulatorId, getExecutableForPlatform(), + ); + if (exePath == null) throw Exception('$name not found. Please download it first.'); + return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal); + } + + @override + String resolveSavePath(Game game) => ''; +} diff --git a/lib/core/emulator/strategies/mame_strategy.dart b/lib/core/emulator/strategies/mame_strategy.dart new file mode 100644 index 0000000..9847c9c --- /dev/null +++ b/lib/core/emulator/strategies/mame_strategy.dart @@ -0,0 +1,69 @@ +import 'dart:io'; +import 'package:freegosy/core/emulator/emulator_strategy.dart'; +import 'package:freegosy/core/romm/romm_models.dart'; +import 'package:freegosy/core/storage/directory_service.dart'; + +class MAMEStrategy extends EmulatorStrategy { + final DirectoryService _directoryService; + + MAMEStrategy(this._directoryService); + + @override + String get name => 'MAME'; + + @override + String get emulatorId => 'mame'; + + @override + List<String> get supportedSlugs => ['arcade', 'mame']; + + @override + String get windowsExecutable => 'mame.exe'; + + @override + String get linuxExecutable => 'mame'; + + @override + bool get supportsSaveSync => false; + + @override + Future<void> launch(Game game, String romPath) async { + final emulatorDir = await _directoryService.getEmulatorDirectory(emulatorId); + final mameExePath = await _directoryService.findEmulatorExecutable( + emulatorId, windowsExecutable, + ); + + if (mameExePath == null) { + // Check for self-extracting exe in the emulator directory + final dir = Directory(emulatorDir); + if (await dir.exists()) { + final files = await dir.list().toList(); + final setupExe = files.firstWhere( + (f) => f is File && f.path.toLowerCase().endsWith('.exe') && !f.path.toLowerCase().endsWith('mame.exe'), + orElse: () => File(''), + ); + + if (setupExe.path.isNotEmpty) { + // It's a self-extracting exe, run it to extract + // We run it with current directory set to emulatorDir so it extracts there + await Process.run(setupExe.path, [], workingDirectory: emulatorDir); + + // Try finding mame.exe again + final retryPath = await _directoryService.findEmulatorExecutable( + emulatorId, windowsExecutable, + ); + if (retryPath != null) { + await Process.start(retryPath, [romPath], mode: ProcessStartMode.detached); + return; + } + } + } + throw Exception('$name not found. Please download it first.'); + } + + await Process.start(mameExePath, [romPath], mode: ProcessStartMode.detached); + } + + @override + String resolveSavePath(Game game) => ''; +} diff --git a/lib/core/emulator/strategies/melonds_strategy.dart b/lib/core/emulator/strategies/melonds_strategy.dart new file mode 100644 index 0000000..345745f --- /dev/null +++ b/lib/core/emulator/strategies/melonds_strategy.dart @@ -0,0 +1,52 @@ +import 'dart:io'; +import 'package:freegosy/core/emulator/emulator_strategy.dart'; +import 'package:freegosy/core/romm/romm_models.dart'; +import 'package:freegosy/core/storage/directory_service.dart'; + +class MelonDSStrategy extends EmulatorStrategy { + final DirectoryService _directoryService; + + MelonDSStrategy(this._directoryService); + + @override + String get name => 'melonDS'; + + @override + String get emulatorId => 'melonds'; + + @override + List<String> get supportedSlugs => ['nds', 'nintendo-ds', 'ds']; + + @override + String get windowsExecutable => 'melonDS.exe'; + + @override + String get linuxExecutable => 'melonDS'; + + @override + bool get supportsSaveSync => false; + + @override + Future<void> launch(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable(emulatorId, getExecutableForPlatform()); + if (exePath == null) throw Exception('$name not found. Please download it first.'); + final workingDir = File(exePath).parent.path; + await Process.start(exePath, [romPath], workingDirectory: workingDir, mode: ProcessStartMode.detached); + } + + @override + Future<Process?> launchWithHandle(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable(emulatorId, getExecutableForPlatform()); + if (exePath == null) throw Exception('$name not found. Please download it first.'); + final workingDir = File(exePath).parent.path; + final process = await Process.start(exePath, [romPath], workingDirectory: workingDir, mode: ProcessStartMode.normal); + process.stdout.drain(); + process.stderr.drain(); + return process; + } + + @override + String resolveSavePath(Game game) { + return ''; // Placeholder + } +} diff --git a/lib/core/emulator/strategies/mgba_strategy.dart b/lib/core/emulator/strategies/mgba_strategy.dart new file mode 100644 index 0000000..8aee232 --- /dev/null +++ b/lib/core/emulator/strategies/mgba_strategy.dart @@ -0,0 +1,49 @@ +import 'dart:io'; +import 'package:freegosy/core/emulator/emulator_strategy.dart'; +import 'package:freegosy/core/romm/romm_models.dart'; +import 'package:freegosy/core/storage/directory_service.dart'; + +class MGBAStrategy extends EmulatorStrategy { + final DirectoryService _directoryService; + + MGBAStrategy(this._directoryService); + + @override + String get name => 'mGBA'; + + @override + String get emulatorId => 'mgba'; + + @override + List<String> get supportedSlugs => ['gba', 'gbc', 'gb', 'game-boy-advance', 'game-boy-color', 'game-boy']; + + @override + String get windowsExecutable => 'mGBA.exe'; + + @override + String get linuxExecutable => 'mgba'; + + @override + bool get supportsSaveSync => false; + + @override + Future<void> launch(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable( + emulatorId, getExecutableForPlatform(), + ); + if (exePath == null) throw Exception('$name not found. Please download it first.'); + await Process.start(exePath, [romPath], mode: ProcessStartMode.detached); + } + + @override + Future<Process?> launchWithHandle(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable( + emulatorId, getExecutableForPlatform(), + ); + if (exePath == null) throw Exception('$name not found. Please download it first.'); + return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal); + } + + @override + String resolveSavePath(Game game) => ''; +} diff --git a/lib/core/emulator/strategies/pcsx2_strategy.dart b/lib/core/emulator/strategies/pcsx2_strategy.dart index 8612e37..8fd512e 100644 --- a/lib/core/emulator/strategies/pcsx2_strategy.dart +++ b/lib/core/emulator/strategies/pcsx2_strategy.dart @@ -37,6 +37,17 @@ class Pcsx2Strategy extends EmulatorStrategy { await Process.start(normalizedExe, [normalizedRom], mode: ProcessStartMode.detached); } + @override + Future<Process?> launchWithHandle(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable( + emulatorId, getExecutableForPlatform(), + ); + if (exePath == null) throw Exception('$name not found. Please download it first.'); + final normalizedRom = romPath.replaceAll('/', '\\'); + final normalizedExe = exePath.replaceAll('/', '\\'); + return await Process.start(normalizedExe, [normalizedRom], mode: ProcessStartMode.normal); + } + @override String resolveSavePath(Game game) => ''; } \ No newline at end of file diff --git a/lib/core/emulator/strategies/ppsspp_strategy.dart b/lib/core/emulator/strategies/ppsspp_strategy.dart new file mode 100644 index 0000000..394f752 --- /dev/null +++ b/lib/core/emulator/strategies/ppsspp_strategy.dart @@ -0,0 +1,49 @@ +import 'dart:io'; +import 'package:freegosy/core/emulator/emulator_strategy.dart'; +import 'package:freegosy/core/romm/romm_models.dart'; +import 'package:freegosy/core/storage/directory_service.dart'; + +class PPSSPPStrategy extends EmulatorStrategy { + final DirectoryService _directoryService; + + PPSSPPStrategy(this._directoryService); + + @override + String get name => 'PPSSPP'; + + @override + String get emulatorId => 'ppsspp'; + + @override + List<String> get supportedSlugs => ['psp', 'playstation-portable']; + + @override + String get windowsExecutable => 'PPSSPPWindows64.exe'; + + @override + String get linuxExecutable => 'PPSSPP'; + + @override + bool get supportsSaveSync => false; + + @override + Future<void> launch(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable( + emulatorId, getExecutableForPlatform(), + ); + if (exePath == null) throw Exception('$name not found. Please download it first.'); + await Process.start(exePath, [romPath], mode: ProcessStartMode.detached); + } + + @override + Future<Process?> launchWithHandle(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable( + emulatorId, getExecutableForPlatform(), + ); + if (exePath == null) throw Exception('$name not found. Please download it first.'); + return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal); + } + + @override + String resolveSavePath(Game game) => ''; +} diff --git a/lib/core/emulator/strategies/retroarch_strategy.dart b/lib/core/emulator/strategies/retroarch_strategy.dart index 9950744..dbff3b6 100644 --- a/lib/core/emulator/strategies/retroarch_strategy.dart +++ b/lib/core/emulator/strategies/retroarch_strategy.dart @@ -1,8 +1,27 @@ -import 'dart:io'; +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:archive/archive_io.dart'; import 'package:freegosy/core/emulator/emulator_strategy.dart'; import 'package:freegosy/core/romm/romm_models.dart'; import 'package:freegosy/core/storage/directory_service.dart'; +class MissingRetroArchCoreException implements Exception { + final String coreName; + final String corePath; + final String exePath; + + MissingRetroArchCoreException({ + required this.coreName, + required this.corePath, + required this.exePath, + }); + + @override + String toString() => 'Missing RetroArch Core: $coreName at $corePath'; +} + class RetroArchStrategy extends EmulatorStrategy { final DirectoryService _directoryService; @@ -16,13 +35,13 @@ class RetroArchStrategy extends EmulatorStrategy { @override List<String> get supportedSlugs => [ - 'gba', 'gbc', 'gb', 'nes', 'snes', 'n64', 'nds', + 'gba', 'gbc', 'gb', 'nes', 'snes', 'n64', 'nds', 'psx', 'ps1', 'playstation', 'psp', - 'segacd', 'saturn', + 'segacd', 'saturn', 'dc', 'dreamcast', 'megadrive', 'genesis', 'md', - 'gamegear', 'atari2600', 'atari7800', 'lynx', 'neogeo', + 'gamegear', 'atari2600', 'atari7800', 'lynx', 'neogeo', 'arcade', 'mame', 'pcengine', 'wonderswan', 'virtualboy', 'msx', 'dos' ]; @@ -77,7 +96,11 @@ class RetroArchStrategy extends EmulatorStrategy { final corePath = '$exeDir\\cores\\$coreName'; if (!await File(corePath).exists()) { - throw Exception('Core $coreName not found at $corePath. Please download it in RetroArch first.'); + throw MissingRetroArchCoreException( + coreName: coreName, + corePath: corePath, + exePath: normalizedExe, + ); } await Process.start( @@ -87,6 +110,66 @@ class RetroArchStrategy extends EmulatorStrategy { ); } + @override + Future<Process?> launchWithHandle(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable( + emulatorId, getExecutableForPlatform()); + if (exePath == null) { + throw Exception('$name not found. Please download it first.'); + } + + final normalizedExe = exePath.replaceAll('/', r'\'); + final normalizedRom = romPath.replaceAll('/', r'\'); + final coreName = _getCoreForSlug(game.platformSlug); + + if (coreName == null) { + return await Process.start( + normalizedExe, + [normalizedRom], + mode: ProcessStartMode.normal, + ); + } + + final exeDir = File(normalizedExe).parent.path; + final corePath = '$exeDir\\cores\\$coreName'; + + if (!await File(corePath).exists()) { + throw MissingRetroArchCoreException( + coreName: coreName, + corePath: corePath, + exePath: normalizedExe, + ); + } + + return await Process.start( + normalizedExe, + ['-L', corePath, normalizedRom], + mode: ProcessStartMode.normal, + ); + } + + Future<void> downloadCore(String coreName, String coresDir, Dio dio) async { + final url = 'https://buildbot.libretro.com/nightly/windows/x86_64/latest/$coreName.zip'; + final tempDir = await getTemporaryDirectory(); + final zipPath = p.join(tempDir.path, '$coreName.zip'); + + try { + await dio.download(url, zipPath); + final bytes = await File(zipPath).readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + for (final entry in archive) { + if (entry.isFile && entry.name.endsWith('.dll')) { + final outFile = File('$coresDir\\${entry.name}'); + await outFile.parent.create(recursive: true); + await outFile.writeAsBytes(entry.content as List<int>); + } + } + } finally { + final f = File(zipPath); + if (await f.exists()) await f.delete(); + } + } + @override String resolveSavePath(Game game) { return ''; diff --git a/lib/core/emulator/strategies/rpcs3_strategy.dart b/lib/core/emulator/strategies/rpcs3_strategy.dart index 9828c67..1df09ca 100644 --- a/lib/core/emulator/strategies/rpcs3_strategy.dart +++ b/lib/core/emulator/strategies/rpcs3_strategy.dart @@ -35,6 +35,15 @@ class Rpcs3Strategy extends EmulatorStrategy { await Process.start(exePath, [romPath], mode: ProcessStartMode.detached); } + @override + Future<Process?> launchWithHandle(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable( + emulatorId, getExecutableForPlatform(), + ); + if (exePath == null) throw Exception('$name not found. Please download it first.'); + return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal); + } + @override String resolveSavePath(Game game) => ''; } \ No newline at end of file diff --git a/lib/core/emulator/strategies/xemu_strategy.dart b/lib/core/emulator/strategies/xemu_strategy.dart index 8d433e8..133272d 100644 --- a/lib/core/emulator/strategies/xemu_strategy.dart +++ b/lib/core/emulator/strategies/xemu_strategy.dart @@ -35,6 +35,15 @@ class XemuStrategy extends EmulatorStrategy { await Process.start(exePath, ['-dvd_path', romPath], mode: ProcessStartMode.detached); } + @override + Future<Process?> launchWithHandle(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable( + emulatorId, getExecutableForPlatform(), + ); + if (exePath == null) throw Exception('$name not found. Please download it first.'); + return await Process.start(exePath, ['-dvd_path', romPath], mode: ProcessStartMode.normal); + } + @override String resolveSavePath(Game game) => ''; } \ No newline at end of file diff --git a/lib/core/emulator/strategies/xenia_strategy.dart b/lib/core/emulator/strategies/xenia_strategy.dart index fac9475..7183a07 100644 --- a/lib/core/emulator/strategies/xenia_strategy.dart +++ b/lib/core/emulator/strategies/xenia_strategy.dart @@ -35,6 +35,15 @@ class XeniaStrategy extends EmulatorStrategy { await Process.start(exePath, [romPath], mode: ProcessStartMode.detached); } + @override + Future<Process?> launchWithHandle(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable( + emulatorId, getExecutableForPlatform(), + ); + if (exePath == null) throw Exception('$name not found. Please download it first.'); + return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal); + } + @override String resolveSavePath(Game game) => ''; } \ No newline at end of file diff --git a/lib/core/emulator/strategy_registry.dart b/lib/core/emulator/strategy_registry.dart index 8c46f8f..b60d6cf 100644 --- a/lib/core/emulator/strategy_registry.dart +++ b/lib/core/emulator/strategy_registry.dart @@ -1,4 +1,6 @@ +import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:freegosy/core/emulator/emulator_strategy.dart'; import 'package:freegosy/core/emulator/strategies/retroarch_strategy.dart'; import 'package:freegosy/core/emulator/strategies/dolphin_strategy.dart'; @@ -8,6 +10,11 @@ import 'package:freegosy/core/emulator/strategies/pcsx2_strategy.dart'; import 'package:freegosy/core/emulator/strategies/azahar_strategy.dart'; import 'package:freegosy/core/emulator/strategies/cemu_strategy.dart'; import 'package:freegosy/core/emulator/strategies/duckstation_strategy.dart'; +import 'package:freegosy/core/emulator/strategies/flycast_strategy.dart'; +import 'package:freegosy/core/emulator/strategies/melonds_strategy.dart'; +import 'package:freegosy/core/emulator/strategies/mgba_strategy.dart'; +import 'package:freegosy/core/emulator/strategies/mame_strategy.dart'; +import 'package:freegosy/core/emulator/strategies/ppsspp_strategy.dart'; import 'package:freegosy/core/emulator/strategies/xemu_strategy.dart'; import 'package:freegosy/core/emulator/strategies/xenia_strategy.dart'; import 'package:freegosy/core/emulator/emulator_registry_data.dart'; @@ -17,9 +24,10 @@ import 'package:freegosy/core/emulator/strategies/windows_strategy.dart'; class StrategyRegistry { final DirectoryService _directoryService; late final List<EmulatorStrategy> _strategies; + final Map<String, String> _slugPreferences = {}; StrategyRegistry(this._directoryService) { - _strategies = [ + final List<EmulatorStrategy> allPossibleStrategies = [ RetroArchStrategy(_directoryService), DolphinStrategy(_directoryService), EdenStrategy(_directoryService), @@ -28,14 +36,123 @@ class StrategyRegistry { AzaharStrategy(_directoryService), CemuStrategy(_directoryService), DuckstationStrategy(_directoryService), + FlycastStrategy(_directoryService), + MelonDSStrategy(_directoryService), + PPSSPPStrategy(_directoryService), + MGBAStrategy(_directoryService), + MAMEStrategy(_directoryService), XemuStrategy(_directoryService), XeniaStrategy(_directoryService), WindowsStrategy(_directoryService), ]; + + _strategies = allPossibleStrategies.where((strategy) { + final definition = getDefinition(strategy.emulatorId); + if (definition == null) return true; // Default to including if no definition found + final supported = List<String>.from(definition['supported_platforms'] ?? []); + if (Platform.isWindows && supported.contains('windows')) return true; + if (Platform.isLinux && supported.contains('linux')) return true; + return false; + }).toList(); + } + + Map<String, List<EmulatorStrategy>> detectConflicts() { + final Map<String, List<EmulatorStrategy>> slugToStrategies = {}; + for (final strategy in _strategies) { + for (final slug in strategy.supportedSlugs) { + slugToStrategies.putIfAbsent(slug, () => []).add(strategy); + } + } + + // Identify slugs with conflicts + final Map<String, List<EmulatorStrategy>> allConflicts = {}; + slugToStrategies.forEach((slug, list) { + if (list.length > 1) { + allConflicts[slug] = list; + } + }); + + if (allConflicts.isEmpty) return {}; + + // Group slugs that have the exact same set of strategies + final Map<String, List<String>> groups = {}; // key: stringified sorted emulator IDs, value: list of slugs + allConflicts.forEach((slug, strategies) { + final ids = strategies.map((s) => s.emulatorId).toList()..sort(); + final key = ids.join('|'); + groups.putIfAbsent(key, () => []).add(slug); + }); + + final Map<String, List<EmulatorStrategy>> canonicalConflicts = {}; + groups.forEach((key, slugs) { + // Pick canonical name: longest slug + final canonical = slugs.reduce((a, b) => a.length > b.length ? a : b); + // Strategies are the same for all slugs in this group + canonicalConflicts[canonical] = allConflicts[slugs.first]!; + }); + + return canonicalConflicts; + } + + String? getPreferredEmulatorId(String slug) => _slugPreferences[slug]; + + Future<void> loadPreferences() async { + final prefs = await SharedPreferences.getInstance(); + for (final key in prefs.getKeys()) { + if (key.startsWith('emulator_pref_')) { + final slug = key.replaceFirst('emulator_pref_', ''); + final emulatorId = prefs.getString(key); + if (emulatorId != null) { + _slugPreferences[slug] = emulatorId; + } + } + } + } + + Future<void> setPreference(String canonicalSlug, String emulatorId) async { + final prefs = await SharedPreferences.getInstance(); + + // Find all slugs that belong to the same group as this canonicalSlug + final slugToStrategies = <String, List<String>>{}; + for (final strategy in _strategies) { + for (final slug in strategy.supportedSlugs) { + slugToStrategies.putIfAbsent(slug, () => []).add(strategy.emulatorId); + } + } + + final targetStrategies = slugToStrategies[canonicalSlug]; + if (targetStrategies == null) { + // Fallback: just set for this slug + await prefs.setString('emulator_pref_$canonicalSlug', emulatorId); + _slugPreferences[canonicalSlug] = emulatorId; + return; + } + + targetStrategies.sort(); + final targetKey = targetStrategies.join('|'); + + // Apply preference to all slugs with the same strategy set + for (final entry in slugToStrategies.entries) { + final ids = entry.value..sort(); + if (ids.join('|') == targetKey) { + final slug = entry.key; + await prefs.setString('emulator_pref_$slug', emulatorId); + _slugPreferences[slug] = emulatorId; + } + } } EmulatorStrategy? getStrategyForSlug(String platformSlug) { if (kIsWeb) return null; + + final preferredId = _slugPreferences[platformSlug]; + if (preferredId != null) { + for (final strategy in _strategies) { + if (strategy.emulatorId == preferredId) { + return strategy; + } + } + } + for (final strategy in _strategies) { if (strategy.supportedSlugs.contains(platformSlug)) { return strategy; diff --git a/lib/core/extraction/extraction_service.dart b/lib/core/extraction/extraction_service.dart new file mode 100644 index 0000000..5880ed9 --- /dev/null +++ b/lib/core/extraction/extraction_service.dart @@ -0,0 +1,70 @@ +import 'dart:io'; +import 'package:archive/archive_io.dart'; +import 'package:flutter/foundation.dart'; +import '../storage/directory_service.dart'; + +Future<void> _extractZipIsolate(List<dynamic> args) async { + final bytes = args[0] as Uint8List; + final destDir = args[1] as String; + final archive = ZipDecoder().decodeBytes(bytes); + extractArchiveToDisk(archive, destDir); +} + +class ExtractionService { + final DirectoryService directoryService; + + ExtractionService(this.directoryService); + + Future<void> extract(String archivePath, String destDir) async { + final pathLower = archivePath.toLowerCase(); + + if (pathLower.endsWith('.zip')) { + final fileBytes = await File(archivePath).readAsBytes(); + await compute(_extractZipIsolate, [fileBytes, destDir]); + } else if (pathLower.endsWith('.7z')) { + final sevenZipExe = await directoryService.resolveSevenZipPath(); + if (sevenZipExe == null) { + throw Exception('7zr.exe could not be initialized. Try reinstalling Freegosy.'); + } + final result = await Process.run( + sevenZipExe, + ['x', archivePath, '-o$destDir', '-y'], + runInShell: false, + ); + if (result.exitCode != 0) { + throw Exception('7z extraction failed: ${result.stderr}'); + } + } else if (pathLower.endsWith('.exe')) { + // Self-extracting archive + var result = await Process.run( + archivePath, + ['-o$destDir', '-y'], + runInShell: false, + ); + if (result.exitCode != 0) { + // Try as a plain self-extractor with no arguments + result = await Process.run( + archivePath, + [], + workingDirectory: destDir, + ); + } + } else { + // Try ZIP magic bytes (PK = 0x50 0x4B) + bool isZip = false; + try { + final raf = await File(archivePath).open(); + final header = await raf.read(4); + await raf.close(); + isZip = header.length >= 2 && header[0] == 0x50 && header[1] == 0x4B; + } catch (_) {} + + if (isZip) { + final fileBytes = await File(archivePath).readAsBytes(); + await compute(_extractZipIsolate, [fileBytes, destDir]); + } else { + throw Exception('Unsupported archive format: $archivePath'); + } + } + } +} diff --git a/lib/core/save/save_strategy.dart b/lib/core/save/save_strategy.dart index e854ffd..52e9077 100644 --- a/lib/core/save/save_strategy.dart +++ b/lib/core/save/save_strategy.dart @@ -32,7 +32,7 @@ abstract class SaveStrategy { if (await bak.exists()) await bak.rename('$path.bak1'); await file.copy('$path.bak'); } catch (e) { - // Error handled silently + // silent } } diff --git a/lib/core/save/save_sync_service.dart b/lib/core/save/save_sync_service.dart index ddda8da..4b3139a 100644 --- a/lib/core/save/save_sync_service.dart +++ b/lib/core/save/save_sync_service.dart @@ -11,10 +11,17 @@ import 'strategies/windows_save_strategy.dart'; import 'strategies/pcsx2_save_strategy.dart'; import 'strategies/rpcs3_save_strategy.dart'; import 'strategies/xenia_save_strategy.dart'; +import 'strategies/duckstation_save_strategy.dart'; +import 'strategies/melonds_save_strategy.dart'; +import 'strategies/mgba_save_strategy.dart'; +import 'strategies/ppsspp_save_strategy.dart'; +import 'strategies/cemu_save_strategy.dart'; +import '../emulator/strategy_registry.dart'; class SaveSyncService { final RommService _rommService; final DirectoryService _directoryService; + final StrategyRegistry _strategyRegistry; late final RetroArchSaveStrategy _retroarch; late final DolphinSaveStrategy _dolphin; @@ -23,35 +30,78 @@ class SaveSyncService { late final Pcsx2SaveStrategy _pcsx2; late final Rpcs3SaveStrategy _rpcs3; late final XeniaSaveStrategy _xenia; + late final DuckstationSaveStrategy _duckstation; + late final MelonDsSaveStrategy _melonds; + late final MgbaSaveStrategy _mgba; + late final PpssppSaveStrategy _ppsspp; + late final CemuSaveStrategy _cemu; - SaveSyncService(this._rommService, this._directoryService) { + SaveSyncService(this._rommService, this._directoryService, this._strategyRegistry) { _retroarch = RetroArchSaveStrategy(_directoryService); _dolphin = DolphinSaveStrategy(_directoryService); _eden = EdenSaveStrategy(); _windows = WindowsSaveStrategy(); _pcsx2 = Pcsx2SaveStrategy(_directoryService); _rpcs3 = Rpcs3SaveStrategy(_directoryService); + _xenia = XeniaSaveStrategy(_directoryService); + _duckstation = DuckstationSaveStrategy(_directoryService); + _melonds = MelonDsSaveStrategy(); + _mgba = MgbaSaveStrategy(); + _ppsspp = PpssppSaveStrategy(_directoryService); + _cemu = CemuSaveStrategy(_directoryService); } /// Returns the appropriate save strategy for [platformSlug], or null if unsupported. SaveStrategy? getStrategyForSlug(String? platformSlug) { + // print('[SaveSync] getStrategyForSlug called with: $platformSlug'); + if (platformSlug != null) { + final preferredId = _strategyRegistry.getPreferredEmulatorId(platformSlug); + // print('[SaveSync] preferredId for $platformSlug: $preferredId'); + if (preferredId != null) { + final id = preferredId.toLowerCase(); + if (id == 'melonds') return _melonds; + if (id == 'mgba') return _mgba; + if (id == 'duckstation') return _duckstation; + if (id == 'retroarch') return _retroarch; + if (id == 'ppsspp') return _ppsspp; + if (id == 'cemu') return _cemu; + if (id == 'pcsx2') return _pcsx2; + if (id == 'rpcs3') return _rpcs3; + if (id == 'dolphin') return _dolphin; + if (id == 'xenia' || id == 'xenia_canary') return _xenia; + if (id == 'eden') return _eden; + if (id == 'windows') return _windows; + } + } + switch (platformSlug?.toLowerCase()) { case 'gba': case 'gbc': case 'gb': + case 'game-boy-advance': + case 'game-boy-color': + case 'game-boy': + return _mgba; case 'snes': case 'nes': case 'n64': + case 'megadrive': + case 'genesis': + case 'md': + return _retroarch; case 'nds': + case 'nintendo-ds': + case 'ds': + return _melonds; case 'psx': case 'ps1': case 'playstation': + return _duckstation; case 'psp': + case 'playstation-portable': + return _ppsspp; case 'dc': case 'dreamcast': - case 'megadrive': - case 'genesis': - case 'md': return _retroarch; case 'gc': case 'ngc': @@ -66,7 +116,6 @@ class SaveSyncService { case 'pc': case 'win': return _windows; - // These emulators don't have save sync yet case 'ps2': case 'playstation-2': case 'playstation2': @@ -78,16 +127,17 @@ class SaveSyncService { case 'xbox360': case 'xbla': return _xenia; + case 'wiiu': + case 'wii-u': + case 'nintendo-wii-u': + case 'nintendo-wiiu': + return _cemu; case '3ds': case 'n3ds': case 'nintendo-3ds': case 'nintendo3ds': case 'new-nintendo-3ds': case 'new-nintendo-3ds-xl': - case 'wiiu': - case 'wii-u': - case 'nintendo-wii-u': - case 'nintendo-wiiu': case 'xbox': return null; default: @@ -156,6 +206,7 @@ class SaveSyncService { if (save == null) { return false; } + // print('[Pull] getLatestSave result: $save'); final downloadUrl = save['download_path'] as String? ?? save['url'] as String?; if (downloadUrl == null) { @@ -166,13 +217,16 @@ class SaveSyncService { if (bytes == null) { return false; } + // print('[Pull] downloaded bytes: ${bytes?.length}'); final filename = save['file_name'] as String? ?? downloadUrl.split('/').last; final ok = await strategy.restoreSave(game, romPath, bytes, filename); + // print('[Pull] restoreSave result: $ok'); return ok; } catch (e) { + // print('[Pull] error: $e'); // Removed print statement rethrow; } } diff --git a/lib/core/save/strategies/cemu_save_strategy.dart b/lib/core/save/strategies/cemu_save_strategy.dart new file mode 100644 index 0000000..814b2d0 --- /dev/null +++ b/lib/core/save/strategies/cemu_save_strategy.dart @@ -0,0 +1,69 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:archive/archive_io.dart'; +import '../../romm/romm_models.dart'; +import '../../storage/directory_service.dart'; +import '../save_strategy.dart'; + +/// Save strategy for Cemu (Wii U). +class CemuSaveStrategy extends SaveStrategy { + final DirectoryService _directoryService; + + CemuSaveStrategy(this._directoryService); + + @override + String get strategyId => 'cemu'; + + Future<String?> _getEmulatorDir() async { + final dir = await _directoryService.getEmulatorDirectory('cemu'); + if (!await Directory(dir).exists()) return null; + return dir.replaceAll('/', '\\'); + } + + @override + Future<String?> getSaveDir(Game game, String romPath) async { + final emuDir = await _getEmulatorDir(); + if (emuDir == null) return null; + return '$emuDir\\mlc01\\usr\\save'; + } + + @override + Future<List<File>> getSaveFiles(Game game, String romPath, + {DateTime? sessionStart, String syncMode = 'both'}) async { + final emuDir = await _getEmulatorDir(); + if (emuDir == null) return []; + final saveRoot = Directory('$emuDir\\mlc01\\usr\\save\00050000円'); + if (!await saveRoot.exists()) return []; + return [File(saveRoot.path)]; // Return the directory as a single item + } + + @override + Future<bool> restoreSave(Game game, String destPath, Uint8List data, String filename) async { + try { + final emuDir = await _getEmulatorDir(); + if (emuDir == null) return false; + final saveRoot = '$emuDir\\mlc01\\usr\\save'; + await Directory(saveRoot).create(recursive: true); + if (filename.toLowerCase().endsWith('.zip')) { + final archive = ZipDecoder().decodeBytes(data); + for (final entry in archive) { + if (entry.name.contains('.bak')) continue; + final entryPath = entry.name.replaceAll('/', '\\'); + if (entryPath.isEmpty || entryPath.endsWith('\\')) continue; + final targetPath = '$saveRoot\\$entryPath'; if (entry.isFile) { + await backupSave(targetPath); + final outFile = File(targetPath); + await outFile.parent.create(recursive: true); + await outFile.writeAsBytes(entry.content as List<int>); + } else { + await Directory(targetPath).create(recursive: true); + } + } + return true; + } + return false; + } catch (e) { + return false; + } + } +} \ No newline at end of file diff --git a/lib/core/save/strategies/duckstation_save_strategy.dart b/lib/core/save/strategies/duckstation_save_strategy.dart new file mode 100644 index 0000000..c4ee211 --- /dev/null +++ b/lib/core/save/strategies/duckstation_save_strategy.dart @@ -0,0 +1,122 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:archive/archive_io.dart'; +import '../../romm/romm_models.dart'; +import '../../storage/directory_service.dart'; +import '../save_strategy.dart'; + +/// Save strategy for DuckStation (PlayStation 1). +class DuckstationSaveStrategy extends SaveStrategy { + final DirectoryService _directoryService; + + DuckstationSaveStrategy(this._directoryService); + + @override + String get strategyId => 'duckstation'; + + Future<String?> _getBaseDir() async { + final exePath = await _directoryService.findEmulatorExecutable( + 'duckstation', 'duckstation-qt-x64-ReleaseLTCG.exe'); + if (exePath == null) return null; + final emulatorDir = File(exePath).parent.path.replaceAll('/', '\\'); + + if (await File('$emulatorDir\\portable.txt').exists()) { + return emulatorDir; + } + + try { + final result = await Process.run('cmd', ['/c', 'echo %LOCALAPPDATA%'], runInShell: false); + final localAppData = result.stdout.toString().trim(); + if (localAppData.isNotEmpty && !localAppData.contains('%')) { + return '$localAppData\\DuckStation'; + } + } catch (e) { + // ignore + } + return emulatorDir; + } + + @override + Future<String?> getSaveDir(Game game, String romPath) async { + final baseDir = await _getBaseDir(); + if (baseDir == null) return null; + return '$baseDir\\memcards'; + } + + @override + Future<List<File>> getSaveFiles(Game game, String romPath, + {DateTime? sessionStart, String syncMode = 'both'}) async { + final baseDir = await _getBaseDir(); + if (baseDir == null) return []; + + final result = <File>[]; + final stem = getRomStem(game); + + final memcardsDir = Directory('$baseDir\\memcards'); + if (await memcardsDir.exists()) { + await for (final entity in memcardsDir.list()) { + if (entity is File && + entity.path.toLowerCase().contains(stem.toLowerCase()) && + entity.path.toLowerCase().endsWith('.mcd')) { + if (sessionStart != null) { + final stat = await entity.stat(); + if (stat.modified.isBefore(sessionStart)) continue; + } + result.add(entity); + } + } + } + + final statesDir = Directory('$baseDir\\savestates'); + if (await statesDir.exists()) { + await for (final entity in statesDir.list()) { + if (entity is File && entity.path.toLowerCase().contains(stem.toLowerCase())) { + if (sessionStart != null) { + final stat = await entity.stat(); + if (stat.modified.isBefore(sessionStart)) continue; + } + result.add(entity); + } + } + } + + return result; + } + + @override + Future<bool> restoreSave( + Game game, String destPath, Uint8List data, String filename) async { + try { + final baseDir = await _getBaseDir(); + if (baseDir == null) return false; + + if (filename.toLowerCase().endsWith('.zip')) { + final archive = ZipDecoder().decodeBytes(data); + for (final entry in archive) { + if (!entry.isFile) continue; + final entryLower = entry.name.toLowerCase(); + final targetDir = entryLower.endsWith('.mcd') + ? '$baseDir\\memcards' + : '$baseDir\\savestates'; + + final targetPath = '$targetDir\\${entry.name.split('\\').last.split('/').last}'; + await backupSave(targetPath); + final outFile = File(targetPath); + await outFile.parent.create(recursive: true); + await outFile.writeAsBytes(entry.content as List<int>); + } + return true; + } + + final isState = !filename.toLowerCase().endsWith('.mcd'); + final targetDir = isState ? '$baseDir\\savestates' : '$baseDir\\memcards'; + final targetPath = '$targetDir\\$filename'; + await Directory(targetDir).create(recursive: true); + await backupSave(targetPath); + await File(targetPath).writeAsBytes(data); + return true; + } catch (e) { + return false; + } + } +} diff --git a/lib/core/save/strategies/melonds_save_strategy.dart b/lib/core/save/strategies/melonds_save_strategy.dart new file mode 100644 index 0000000..641e397 --- /dev/null +++ b/lib/core/save/strategies/melonds_save_strategy.dart @@ -0,0 +1,68 @@ +import 'dart:io'; +import 'dart:typed_data'; +import '../../romm/romm_models.dart'; +import '../save_strategy.dart'; + +/// Save strategy for melonDS (Nintendo DS). +class MelonDsSaveStrategy extends SaveStrategy { + @override + String get strategyId => 'melonds'; + + @override + Future<String?> getSaveDir(Game game, String romPath) async { + return File(romPath).parent.path; + } + + @override + Future<List<File>> getSaveFiles(Game game, String romPath, + {DateTime? sessionStart, String syncMode = 'both'}) async { + final romFile = File(romPath); + final romStem = romFile.uri.pathSegments.last.replaceAll(RegExp(r'\.[^.]+$'), ''); + final saveFile = File('${File(romPath).parent.path}/$romStem.sav'); + + if (await saveFile.exists()) { + if (sessionStart != null) { + final stat = await saveFile.stat(); + if (stat.modified.isBefore(sessionStart)) return []; + } + return [saveFile]; + } else { + // Fallback to getRomStem(game) + final fallbackStem = getRomStem(game); + final fallbackSaveFile = File('${File(romPath).parent.path}/$fallbackStem.sav'); + if (await fallbackSaveFile.exists()) { + if (sessionStart != null) { + final stat = await fallbackSaveFile.stat(); + if (stat.modified.isBefore(sessionStart)) return []; + } + return [fallbackSaveFile]; + } + } + return []; + } + + @override + Future<bool> restoreSave( + Game game, String destPath, Uint8List data, String filename) async { + try { + final romFile = File(destPath); + final romStem = romFile.uri.pathSegments.last.replaceAll(RegExp(r'\.[^.]+$'), ''); + final targetPath = '${File(destPath).parent.path}/$romStem.sav'; + + if (await File(targetPath).exists()) { + await backupSave(targetPath); + await File(targetPath).writeAsBytes(data); + return true; + } else { + // Fallback to getRomStem(game) + final fallbackStem = getRomStem(game); + final fallbackTargetPath = '${File(destPath).parent.path}/$fallbackStem.sav'; + await backupSave(fallbackTargetPath); + await File(fallbackTargetPath).writeAsBytes(data); + return true; + } + } catch (e) { + return false; + } + } +} diff --git a/lib/core/save/strategies/mgba_save_strategy.dart b/lib/core/save/strategies/mgba_save_strategy.dart new file mode 100644 index 0000000..6cdde1b --- /dev/null +++ b/lib/core/save/strategies/mgba_save_strategy.dart @@ -0,0 +1,68 @@ +import 'dart:io'; +import 'dart:typed_data'; +import '../../romm/romm_models.dart'; +import '../save_strategy.dart'; + +/// Save strategy for mGBA (GBA/GBC/GB). +class MgbaSaveStrategy extends SaveStrategy { + @override + String get strategyId => 'mgba'; + + @override + Future<String?> getSaveDir(Game game, String romPath) async { + return File(romPath).parent.path; + } + + @override + Future<List<File>> getSaveFiles(Game game, String romPath, + {DateTime? sessionStart, String syncMode = 'both'}) async { + final romFile = File(romPath); + final romStem = romFile.uri.pathSegments.last.replaceAll(RegExp(r'\.[^.]+$'), ''); + final saveFile = File('${File(romPath).parent.path}/$romStem.sav'); + + if (await saveFile.exists()) { + if (sessionStart != null) { + final stat = await saveFile.stat(); + if (stat.modified.isBefore(sessionStart)) return []; + } + return [saveFile]; + } else { + // Fallback to getRomStem(game) + final fallbackStem = getRomStem(game); + final fallbackSaveFile = File('${File(romPath).parent.path}/$fallbackStem.sav'); + if (await fallbackSaveFile.exists()) { + if (sessionStart != null) { + final stat = await fallbackSaveFile.stat(); + if (stat.modified.isBefore(sessionStart)) return []; + } + return [fallbackSaveFile]; + } + } + return []; + } + + @override + Future<bool> restoreSave( + Game game, String destPath, Uint8List data, String filename) async { + try { + final romFile = File(destPath); + final romStem = romFile.uri.pathSegments.last.replaceAll(RegExp(r'\.[^.]+$'), ''); + final targetPath = '${File(destPath).parent.path}/$romStem.sav'; + + if (await File(targetPath).exists()) { + await backupSave(targetPath); + await File(targetPath).writeAsBytes(data); + return true; + } else { + // Fallback to getRomStem(game) + final fallbackStem = getRomStem(game); + final fallbackTargetPath = '${File(destPath).parent.path}/$fallbackStem.sav'; + await backupSave(fallbackTargetPath); + await File(fallbackTargetPath).writeAsBytes(data); + return true; + } + } catch (e) { + return false; + } + } +} diff --git a/lib/core/save/strategies/pcsx2_save_strategy.dart b/lib/core/save/strategies/pcsx2_save_strategy.dart index 7a44a61..abc7446 100644 --- a/lib/core/save/strategies/pcsx2_save_strategy.dart +++ b/lib/core/save/strategies/pcsx2_save_strategy.dart @@ -39,35 +39,31 @@ class Pcsx2SaveStrategy extends SaveStrategy { final result = <File>[]; // Memory cards — scan all .ps2 files in memcards folder - if (syncMode == 'saves' || syncMode == 'both') { - final memcardsDir = Directory('$exeDir\\memcards'); - if (await memcardsDir.exists()) { - await for (final entity in memcardsDir.list()) { - if (entity is! File) continue; - if (!entity.path.toLowerCase().endsWith('.ps2')) continue; - if (sessionStart != null) { - final stat = await entity.stat(); - if (stat.modified.isBefore(sessionStart)) continue; - } - result.add(entity); + final memcardsDir = Directory('$exeDir\\memcards'); + if (await memcardsDir.exists()) { + await for (final entity in memcardsDir.list()) { + if (entity is! File) continue; + if (!entity.path.toLowerCase().endsWith('.ps2')) continue; + if (sessionStart != null) { + final stat = await entity.stat(); + if (stat.modified.isBefore(sessionStart)) continue; } + result.add(entity); } } // Save states — named after ROM stem - if (syncMode == 'states' || syncMode == 'both') { - final stem = getRomStem(game); - final statesDir = Directory('$exeDir\\sstates'); - if (await statesDir.exists()) { - await for (final entity in statesDir.list()) { - if (entity is! File) continue; - if (!entity.path.contains(stem)) continue; - if (sessionStart != null) { - final stat = await entity.stat(); - if (stat.modified.isBefore(sessionStart)) continue; - } - result.add(entity); + final stem = getRomStem(game); + final statesDir = Directory('$exeDir\\sstates'); + if (await statesDir.exists()) { + await for (final entity in statesDir.list()) { + if (entity is! File) continue; + if (!entity.path.contains(stem)) continue; + if (sessionStart != null) { + final stat = await entity.stat(); + if (stat.modified.isBefore(sessionStart)) continue; } + result.add(entity); } } diff --git a/lib/core/save/strategies/ppsspp_save_strategy.dart b/lib/core/save/strategies/ppsspp_save_strategy.dart new file mode 100644 index 0000000..b62336f --- /dev/null +++ b/lib/core/save/strategies/ppsspp_save_strategy.dart @@ -0,0 +1,108 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:archive/archive_io.dart'; +import '../../romm/romm_models.dart'; +import '../../storage/directory_service.dart'; +import '../save_strategy.dart'; + +/// Save strategy for PPSSPP (PSP). +class PpssppSaveStrategy extends SaveStrategy { + final DirectoryService _directoryService; + + PpssppSaveStrategy(this._directoryService); + + @override + String get strategyId => 'ppsspp'; + + Future<String?> _getEmulatorDir() async { + final exePath = await _directoryService.findEmulatorExecutable( + 'ppsspp', 'PPSSPPWindows64.exe'); + if (exePath == null) return null; + return File(exePath).parent.path.replaceAll('/', '\\'); + } + + @override + Future<String?> getSaveDir(Game game, String romPath) async { + final emuDir = await _getEmulatorDir(); + if (emuDir == null) return null; + return '$emuDir\\memstick\\PSP\\SAVEDATA'; + } + + @override + Future<List<File>> getSaveFiles(Game game, String romPath, + {DateTime? sessionStart, String syncMode = 'both'}) async { + final emuDir = await _getEmulatorDir(); + if (emuDir == null) return []; + + final result = <File>[]; + final stem = getRomStem(game); + + final saveDataDir = Directory('$emuDir\\memstick\\PSP\\SAVEDATA'); + if (await saveDataDir.exists()) { + bool hasFiles = false; + await for (final _ in saveDataDir.list(recursive: true)) { + hasFiles = true; + break; + } + if (hasFiles) { + result.add(File(saveDataDir.path)); + } + } + + final statesDir = Directory('$emuDir\\memstick\\PSP\\PPSSPP_STATE'); + if (await statesDir.exists()) { + final stateFile = File('${statesDir.path}\\$stem.ppst'); + if (await stateFile.exists()) { + if (sessionStart == null || (await stateFile.stat()).modified.isAfter(sessionStart)) { + result.add(stateFile); + } + } + } + + return result; + } + + @override + Future<bool> restoreSave( + Game game, String destPath, Uint8List data, String filename) async { + try { + final emuDir = await _getEmulatorDir(); + if (emuDir == null) return false; + + if (filename.toLowerCase().endsWith('.zip')) { + final archive = ZipDecoder().decodeBytes(data); + final targetBaseDir = '$emuDir\\memstick\\PSP\\SAVEDATA'; + for (final entry in archive) { + if (entry.name.contains('.bak')) continue; + final entryPath = entry.name.replaceAll('/', '\\'); + final segments = entryPath.split('\\'); + final strippedPath = segments.length > 1 ? segments.skip(1).join('\\') : entryPath; + if (strippedPath.isEmpty) continue; + final targetPath = '$targetBaseDir\\$strippedPath'; + if (entry.isFile) { + await backupSave(targetPath); + final outFile = File(targetPath); + await outFile.parent.create(recursive: true); + await outFile.writeAsBytes(entry.content as List<int>); + } else { + await Directory(targetPath).create(recursive: true); + } + } + return true; + } + + if (filename.toLowerCase().endsWith('.ppst')) { + final targetPath = '$emuDir\\memstick\\PSP\\PPSSPP_STATE\\$filename'; + await backupSave(targetPath); + final outFile = File(targetPath); + await outFile.parent.create(recursive: true); + await outFile.writeAsBytes(data); + return true; + } + + return false; + } catch (e) { + return false; + } + } +} diff --git a/lib/core/save/strategies/retroarch_save_strategy.dart b/lib/core/save/strategies/retroarch_save_strategy.dart index b9accd9..4767e4e 100644 --- a/lib/core/save/strategies/retroarch_save_strategy.dart +++ b/lib/core/save/strategies/retroarch_save_strategy.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import '../../romm/romm_models.dart'; import '../../storage/directory_service.dart'; import '../save_strategy.dart'; +import 'package:path/path.dart' as p; // Import path package /// Save strategy for RetroArch emulator. /// @@ -17,23 +18,24 @@ class RetroArchSaveStrategy extends SaveStrategy { @override String get strategyId => 'retroarch'; + // _CoreInfo maps platform slugs to RetroArch core info, including save and state directories. static const Map<String, _CoreInfo> _coreMap = { - 'gba': _CoreInfo('mgba_libretro', 'mGBA', 'mGBA'), - 'gbc': _CoreInfo('mgba_libretro', 'mGBA', 'mGBA'), - 'gb': _CoreInfo('mgba_libretro', 'mGBA', 'mGBA'), - 'snes': _CoreInfo('snes9x_libretro', 'Snes9X', 'Snes9X'), - 'nes': _CoreInfo('fceumm_libretro', 'FCEUmm', 'FCEUmm'), - 'n64': _CoreInfo('mupen64plus_next_libretro', 'Mupen64Plus-Next', 'Mupen64Plus-Next'), - 'nds': _CoreInfo('desmume2015_libretro', 'DeSmuME 2015', 'DeSmuME 2015'), - 'psx': _CoreInfo('pcsx_rearmed_libretro', 'PCSX-ReARMed', 'PCSX-ReARMed'), - 'psp': _CoreInfo('ppsspp_libretro', 'PPSSPP/PSP/SAVEDATA', 'PPSSPP'), - 'dreamcast': _CoreInfo('flycast_libretro', 'Flycast', 'Flycast'), - 'megadrive': _CoreInfo('genesis_plus_gx_libretro', 'Genesis Plus GX', 'Genesis Plus GX'), - 'dc': _CoreInfo('flycast_libretro', 'Flycast', 'Flycast'), - 'ps1': _CoreInfo('pcsx_rearmed_libretro', 'PCSX-ReARMed', 'PCSX-ReARMed'), - 'playstation': _CoreInfo('pcsx_rearmed_libretro', 'PCSX-ReARMed', 'PCSX-ReARMed'), - 'md': _CoreInfo('genesis_plus_gx_libretro', 'Genesis Plus GX', 'Genesis Plus GX'), - 'genesis': _CoreInfo('genesis_plus_gx_libretro', 'Genesis Plus GX', 'Genesis Plus GX'), + 'gba': _CoreInfo('mgba_libretro', 'GBA', 'States/GBA'), + 'gbc': _CoreInfo('mgba_libretro', 'GBA', 'States/GBA'), // mGBA uses the same save folder for GBA/GBC/GB + 'gb': _CoreInfo('mgba_libretro', 'GBA', 'States/GBA'), + 'snes': _CoreInfo('snes9x_libretro', 'SNES', 'States/SNES'), + 'nes': _CoreInfo('fceumm_libretro', 'NES', 'States/NES'), + 'n64': _CoreInfo('mupen64plus_next_libretro', 'N64', 'States/N64'), + 'nds': _CoreInfo('desmume2015_libretro', 'NDS', 'States/NDS'), + 'psx': _CoreInfo('pcsx_rearmed_libretro', 'PSX', 'States/PSX'), + 'psp': _CoreInfo('ppsspp_libretro', 'PPSSPP/PSP/SAVEDATA', 'PPSSPP'), // Note: saveFolder is 'PPSSPP/PSP/SAVEDATA', statesFolder is 'PPSSPP' + 'playstation': _CoreInfo('pcsx_rearmed_libretro', 'PCSX-ReARMed', 'PCSX-ReARMed'), // Assuming this is also PSX and needs save/state dir logic + 'playstation-portable': _CoreInfo('ppsspp_libretro', 'PPSSPP/PSP/SAVEDATA', 'PPSSPP'), // Alias for PSP + 'dreamcast': _CoreInfo('flycast_libretro', 'Dreamcast', 'States/Dreamcast'), + 'dc': _CoreInfo('flycast_libretro', 'Dreamcast', 'States/Dreamcast'), // Alias for Dreamcast + 'megadrive': _CoreInfo('genesis_plus_gx_libretro', 'Mega Drive', 'States/Mega Drive'), + 'genesis': _CoreInfo('genesis_plus_gx_libretro', 'Mega Drive', 'States/Mega Drive'), // Alias for Mega Drive + 'md': _CoreInfo('genesis_plus_gx_libretro', 'Mega Drive', 'States/Mega Drive'), // Alias for Mega Drive }; @override @@ -46,7 +48,8 @@ class RetroArchSaveStrategy extends SaveStrategy { if (exePath == null) return null; final exeDir = File(exePath).parent.path; - return '$exeDir/saves/${coreInfo.saveFolder}'; + // The saveFolder in _coreMap is relative to the RetroArch installation directory. + return p.join(exeDir, 'saves', coreInfo.saveFolder); } @override @@ -59,32 +62,63 @@ class RetroArchSaveStrategy extends SaveStrategy { if (exePath == null) return []; final exeDir = File(exePath).parent.path; - final stem = getRomStem(game); - final candidates = <File>[]; - - if (syncMode == 'saves' || syncMode == 'both') { - final savesDir = '$exeDir/saves/${coreInfo.saveFolder}'; - candidates.add(File('$savesDir/$stem.srm')); + final stem = getRomStem(game); // Assuming getRomStem is available and correct for generating base filename + + final List<File> filesToReturn = []; + + // Special case for PSP saves + if (slug == 'psp' || slug == 'playstation-portable') { + if (syncMode == 'saves' || syncMode == 'both') { + final pspPath = '$exeDir\\saves\\PPSSPP\\PSP'.replaceAll('/', '\\'); + final pspDir = Directory(pspPath); + if (await pspDir.exists()) { + bool hasFiles = false; + await for (final _ in pspDir.list(recursive: true)) { + hasFiles = true; + break; + } + if (hasFiles) { + filesToReturn.add(File(pspPath)); + } + } + } + } else { + // Existing logic for non-PSP saves (e.g., SRM files) + if (syncMode == 'saves' || syncMode == 'both') { + final savesDir = p.join(exeDir, 'saves', coreInfo.saveFolder); + filesToReturn.add(File(p.join(savesDir, '$stem.srm'))); + } } + // Handle States (regardless of platform) if (syncMode == 'states' || syncMode == 'both') { - final statesDir = '$exeDir/states/${coreInfo.statesFolder}'; - candidates.add(File('$statesDir/$stem.state.auto')); - for (int i = 0; i <= 9; i++) { - candidates.add(File('$statesDir/$stem.state$i')); + final statesDir = p.join(exeDir, 'states', coreInfo.statesFolder); + + // Derive stem from romPath as fallback + final romStem = File(romPath).uri.pathSegments.last.replaceAll(RegExp(r'\.[^.]+$'), ''); + + // Check state files for both stems + for (final checkStem in [stem, romStem]) { + filesToReturn.add(File('$statesDir/$checkStem.state.auto')); + for (int i = 0; i <= 9; i++) { + filesToReturn.add(File('$statesDir/$checkStem.state$i')); + } } } - final result = <File>[]; - for (final f in candidates) { - if (!await f.exists()) continue; - if (sessionStart != null) { + // Filter out non-existent files and apply sessionStart filter + final finalResult = <File>[]; + for (final f in filesToReturn) { + final existsAsFile = await f.exists(); + final existsAsDir = await Directory(f.path).exists(); + if (!existsAsFile && !existsAsDir) continue; + if (sessionStart != null && existsAsFile) { final stat = await f.stat(); if (stat.modified.isBefore(sessionStart)) continue; } - result.add(f); + finalResult.add(f); } - return result; + return finalResult; } @override @@ -100,14 +134,14 @@ class RetroArchSaveStrategy extends SaveStrategy { final isState = filename.contains('.state'); final targetDir = isState - ? '$exeDir/states/${coreInfo.statesFolder}' - : '$exeDir/saves/${coreInfo.saveFolder}'; + ? p.join(exeDir, 'states', coreInfo.statesFolder) + : p.join(exeDir, 'saves', coreInfo.saveFolder); final dir = Directory(targetDir); if (!await dir.exists()) await dir.create(recursive: true); - final targetPath = '$targetDir/$filename'; - await backupSave(targetPath); + final targetPath = p.join(targetDir, filename); + await backupSave(targetPath); // Backup existing file await File(targetPath).writeAsBytes(data); return true; } catch (e) { @@ -121,4 +155,4 @@ class _CoreInfo { final String saveFolder; final String statesFolder; const _CoreInfo(this.coreName, this.saveFolder, this.statesFolder); -} \ No newline at end of file +} diff --git a/lib/core/storage/directory_service.dart b/lib/core/storage/directory_service.dart index 90f3fa9..e71e873 100644 --- a/lib/core/storage/directory_service.dart +++ b/lib/core/storage/directory_service.dart @@ -36,6 +36,7 @@ class DirectoryService { late String romsRootPath; late String emulatorsRootPath; + final Map<String, String> _emulatorPathOverrides = {}; DirectoryService(); @@ -45,8 +46,30 @@ class DirectoryService { emulatorsRootPath = prefs.getString(_emulatorsRootPathKey) ?? _defaultEmulatorsPath; await _ensureDirectoryExists(romsRootPath); await _ensureDirectoryExists(emulatorsRootPath); + await loadEmulatorPathOverrides(); } + Future<void> loadEmulatorPathOverrides() async { + final prefs = await SharedPreferences.getInstance(); + for (final key in prefs.getKeys()) { + if (key.startsWith('emu_path_')) { + final emuId = key.replaceFirst('emu_path_', ''); + final path = prefs.getString(key); + if (path != null) { + _emulatorPathOverrides[emuId] = path; + } + } + } + } + + Future<void> setEmulatorPathOverride(String emulatorId, String path) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('emu_path_$emulatorId', path); + _emulatorPathOverrides[emulatorId] = path; + } + + String? getEmulatorPathOverride(String emulatorId) => _emulatorPathOverrides[emulatorId]; + Future<void> _ensureDirectoryExists(String path) async { final directory = Directory(path); if (!await directory.exists()) { @@ -152,9 +175,19 @@ class DirectoryService { return null; } if (appData.isEmpty || appData.contains('%APPDATA%')) return null; + } else if (defaultTargetPlatform == TargetPlatform.linux) { + // Linux: Try to find system 7z + try { + final result = await Process.run('which', ['7z']); + if (result.exitCode == 0) { + return result.stdout.toString().trim(); + } + } catch (e) { + // ignore + } + return null; } else { - // macOS/Linux: no 7zr needed, system 7z is installable via package managers - // Return null for now — future: resolve via `which 7z` + // macOS: no 7zr needed return null; } @@ -173,6 +206,9 @@ class DirectoryService { } Future<String> getEmulatorDirectory(String emulatorId) async { + final override = getEmulatorPathOverride(emulatorId); + if (override != null) return override; + final dirPath = '$emulatorsRootPath/$emulatorId'; await _ensureDirectoryExists(dirPath); return dirPath; diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index db832c1..6c386bd 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -1,13 +1,30 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:dio/dio.dart'; import '../core/downloader/download_service.dart'; +import '../core/emulator/emulator_download_service.dart'; +import '../core/extraction/extraction_service.dart'; import '../core/romm/romm_models.dart'; import 'romm_provider.dart'; final downloadServiceProvider = FutureProvider<DownloadService?>((ref) async { final directoryService = await ref.watch(directoryServiceProvider.future); if (directoryService == null) return null; - return DownloadService(dio: Dio(), directoryService: directoryService); + return DownloadService( + dio: Dio(), + directoryService: directoryService, + extractionService: ExtractionService(directoryService), + ); +}); + +final emulatorDownloadServiceProvider = + FutureProvider<EmulatorDownloadService?>((ref) async { + final directoryService = await ref.watch(directoryServiceProvider.future); + if (directoryService == null) return null; + return EmulatorDownloadService( + Dio(), + directoryService, + ExtractionService(directoryService), + ); }); final downloadProvider = @@ -20,7 +37,8 @@ class DownloadNotifier extends StateNotifier<Map<String, DownloadProgress>> { DownloadNotifier(this._ref) : super({}); - Future<void> startDownload(Game game, String downloadUrl, {Map<String, String>? headers}) async { + Future<void> startDownload(Game game, String downloadUrl, + {Map<String, String>? headers}) async { final service = await _ref.read(downloadServiceProvider.future); if (service == null) return; service.download(game, downloadUrl, headers: headers).listen((progress) { @@ -28,6 +46,15 @@ class DownloadNotifier extends StateNotifier<Map<String, DownloadProgress>> { }); } + Future<void> startEmulatorDownload( + String emulatorId, String emulatorName) async { + final service = await _ref.read(emulatorDownloadServiceProvider.future); + if (service == null) return; + service.downloadEmulator(emulatorId).listen((progress) { + state = {...state, emulatorId: progress}; + }); + } + void removeDownload(String gameId) { final newState = Map<String, DownloadProgress>.from(state); newState.remove(gameId); diff --git a/lib/providers/library_provider.dart b/lib/providers/library_provider.dart index 7432bda..e394015 100644 --- a/lib/providers/library_provider.dart +++ b/lib/providers/library_provider.dart @@ -233,7 +233,6 @@ final platformsProvider = FutureProvider<List<Platform>>((ref) async { final fresh = await service.getPlatforms(); if (fresh.isNotEmpty) { await _savePlatformsCache(fresh); - ref.invalidateSelf(); } } catch (_) {} }); @@ -261,7 +260,6 @@ final allGamesProvider = FutureProvider<List<Game>>((ref) async { final fresh = await service.getAllGames(); if (fresh.isNotEmpty) { await _saveGamesCache(fresh); - ref.invalidateSelf(); } } catch (_) {} }); diff --git a/lib/providers/romm_provider.dart b/lib/providers/romm_provider.dart index ff962b6..3924d3a 100644 --- a/lib/providers/romm_provider.dart +++ b/lib/providers/romm_provider.dart @@ -40,11 +40,12 @@ final directoryServiceProvider = FutureProvider<DirectoryService?>((ref) async { }); // Provider for StrategyRegistry -final strategyRegistryProvider = Provider<StrategyRegistry?>((ref) { +final strategyRegistryProvider = FutureProvider<StrategyRegistry?>((ref) async { final directoryService = ref.watch(directoryServiceProvider).value; if (directoryService != null) { try { final registry = StrategyRegistry(directoryService); + await registry.loadPreferences(); // Await preferences loading // Load persisted Windows exe overrides final winStrategy = registry.getStrategyForSlug('windows'); if (winStrategy is WindowsStrategy) { @@ -59,12 +60,12 @@ final strategyRegistryProvider = Provider<StrategyRegistry?>((ref) { }); // SaveSyncService provider -final saveSyncServiceProvider = Provider<SaveSyncService?>((ref) { +final saveSyncServiceProvider = FutureProvider<SaveSyncService?>((ref) async { final rommService = ref.watch(rommServiceProvider); final directoryService = ref.watch(directoryServiceProvider).asData?.value; - if (rommService == null || directoryService == null) return null; - final service = SaveSyncService(rommService, directoryService); - // Load persisted Windows save path overrides + final strategyRegistry = await ref.watch(strategyRegistryProvider.future); + if (rommService == null || directoryService == null || strategyRegistry == null) return null; + final service = SaveSyncService(rommService, directoryService, strategyRegistry); service.windowsSaveStrategy.loadPersistedOverrides(); return service; }); diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart index e4379e0..cc7ebe7 100644 --- a/lib/ui/screens/library_screen.dart +++ b/lib/ui/screens/library_screen.dart @@ -1,3 +1,7 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -6,11 +10,12 @@ import '../../providers/download_provider.dart'; import '../../providers/romm_provider.dart'; import '../../core/storage/directory_service.dart'; import '../../core/romm/romm_models.dart'; +import '../../core/emulator/strategies/windows_strategy.dart'; +import '../../core/emulator/strategies/retroarch_strategy.dart'; import '../widgets/game_card.dart'; import '../widgets/platform_filter_bar.dart'; import '../widgets/windows_game_config_dialog.dart'; -import '../../core/emulator/strategies/windows_strategy.dart'; -import 'dart:convert'; +import 'library_skeleton.dart'; class LibraryScreen extends ConsumerStatefulWidget { const LibraryScreen({super.key}); @@ -26,15 +31,12 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> { Future<void> _loadDownloadStates( DirectoryService dirService, List<Game> games) async { if (_downloadStatesLoaded) return; - final results = await Future.wait( games.map((game) async { - final isDownloaded = - await dirService.isRomDownloaded(game); + final isDownloaded = await dirService.isRomDownloaded(game); return MapEntry(game.id, isDownloaded); }), ); - if (mounted) { setState(() { _downloadedStates = Map.fromEntries(results); @@ -53,12 +55,40 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> { } } - Future<void> _handleLaunch(BuildContext context, WidgetRef ref, game) async { - final registry = ref.read(strategyRegistryProvider); - final strategy = registry?.getStrategyForSlug(game.platformSlug ?? ''); + void _startDownload(BuildContext context, WidgetRef ref, Game game) { + final service = ref.read(rommServiceProvider); + if (service == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Not connected to RomM')), + ); + return; + } + final url = service.getDownloadUrl(game); + final basicAuth = + 'Basic ${base64Encode(utf8.encode('${service.config.username}:${service.config.password}'))}'; + final headers = <String, String>{'Authorization': basicAuth}; + ref.read(downloadProvider.notifier).startDownload(game, url, headers: headers); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Downloading ${game.name}...')), + ); + final dirService = ref.read(directoryServiceProvider).asData?.value; + if (dirService != null) { + Future.delayed(const Duration(seconds: 2), () { + _refreshDownloadState(dirService, game); + }); + } + } + + Future<void> _handleLaunch(BuildContext context, WidgetRef ref, Game game) async { + final messenger = ScaffoldMessenger.of(context); + + // Ensure strategy registry preferences are loaded + final registryReady = await ref.read(strategyRegistryProvider.future); + if (registryReady == null) return; + final strategy = registryReady.getStrategyForSlug(game.platformSlug ?? ''); if (strategy == null) { - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( SnackBar( content: Text( 'No emulator configured for ${game.platformDisplayName ?? game.platformSlug ?? 'this platform'}')), @@ -66,10 +96,13 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> { return; } + // Wait for save sync service to be ready (FutureProvider chain) + final syncService = await ref.read(saveSyncServiceProvider.future); + final dir = await ref.read(directoryServiceProvider.future); if (!context.mounted) return; if (dir == null) { - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( const SnackBar(content: Text('Storage service not available')), ); return; @@ -118,7 +151,6 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> { ], ), ); - if (!context.mounted) return; if (shouldDownload == true) { _startDownload(context, ref, game); @@ -126,18 +158,22 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> { return; } - // Pull latest cloud save before launching - final syncService = ref.read(saveSyncServiceProvider); if (syncService != null) { - // Push local saves first so nothing is lost, then pull cloud save final syncMode = ref.read(retroarchSyncModeProvider); + messenger.showSnackBar( + SnackBar( + content: Text('Pushing saves for ${game.name}...'), + duration: const Duration(seconds: 30), + ), + ); await syncService.pushSaves(game, existingRomPath, syncMode: syncMode); if (!context.mounted) return; + messenger.clearSnackBars(); try { final pulled = await syncService.pullSave(game, existingRomPath); if (!context.mounted) return; if (pulled) { - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( const SnackBar( content: Text('Cloud save restored'), duration: Duration(seconds: 2)), @@ -169,9 +205,97 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> { } try { - await strategy.launch(game, existingRomPath); + messenger.showSnackBar( + SnackBar( + content: Text('Launching ${game.name}...'), + duration: const Duration(seconds: 3), + ), + ); + + // Try to get process handle for auto-sync when game closes + Process? process = await strategy.launchWithHandle(game, existingRomPath); + if (process == null) { + // Fall back to regular launch if no process handle available + await strategy.launch(game, existingRomPath); + } else { + // Start background Future to handle process exit + unawaited(Future.delayed(Duration.zero, () async { + try { + await process.exitCode; + if (!context.mounted) return; + + messenger.showSnackBar( + const SnackBar( + content: Text('Auto-syncing saves...'), + duration: Duration(seconds: 2), + ), + ); + + if (syncService != null) { + final syncMode = ref.read(retroarchSyncModeProvider); + await syncService.pushSaves(game, existingRomPath, syncMode: syncMode); + } + + if (!context.mounted) return; + messenger.showSnackBar( + const SnackBar( + content: Text('Saves synced'), + duration: Duration(seconds: 2), + ), + ); + } catch (e) { + // Silently ignore errors in auto-sync + } + })); + } } catch (e) { if (!context.mounted) return; + + if (e is MissingRetroArchCoreException) { + final shouldDownload = await showDialog<bool>( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('RetroArch Core Missing'), + content: Text( + 'The core ${e.coreName} is required for this game but is not installed. Would you like Freegosy to download and install it automatically?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Install'), + ), + ], + ), + ); + if (shouldDownload == true && context.mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => const Center(child: CircularProgressIndicator()), + ); + try { + final raStrategy = strategy as RetroArchStrategy; + final coresDir = File(e.corePath).parent.path; + await raStrategy.downloadCore(e.coreName, coresDir, Dio()); + if (context.mounted) { + Navigator.of(context).pop(); + await _handleLaunch(context, ref, game); + } + } catch (err) { + if (context.mounted) { + Navigator.of(context).pop(); + messenger.showSnackBar( + SnackBar(content: Text('Failed to download core: $err')), + ); + } + } + } + return; + } + final isWindows = ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? ''); final isMissingExe = @@ -179,7 +303,7 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> { if (isWindows && isMissingExe) { await _handleWindowsConfig(context, ref, game); } else { - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( SnackBar( content: Text('Launch failed: $e'), duration: const Duration(seconds: 8), @@ -191,13 +315,13 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> { Future<void> _handleWindowsConfig( BuildContext context, WidgetRef ref, Game game) async { - final registry = ref.read(strategyRegistryProvider); + final registry = ref.read(strategyRegistryProvider).asData?.value; final windowsStrategy = registry?.getStrategyForSlug(game.platformSlug ?? '') as WindowsStrategy?; - final syncService = ref.read(saveSyncServiceProvider); - - final result = await showDialog<Map<String, String>>( - context: context, + final syncService = await ref.read(saveSyncServiceProvider.future); + if (!context.mounted) return; + final result = await showDialog<Map<String, String>>( + context: context, builder: (ctx) => WindowsGameConfigDialog( game: game, currentExePath: windowsStrategy?.getExeOverride(game.id), @@ -205,44 +329,40 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> { syncService?.windowsSaveStrategy.getManualOverride(game.id), ), ); - if (result == null) return; // user cancelled - + if (result == null) return; final exe = result['exe'] ?? ''; final save = result['save'] ?? ''; - if (exe.isNotEmpty) { await windowsStrategy?.setExeOverride(game.id, exe); } if (save.isNotEmpty) { await syncService?.windowsSaveStrategy.setManualOverride(game.id, save); } - if (!context.mounted) return; await _handleLaunch(context, ref, game); } Future<void> _handleSyncSaves( BuildContext context, WidgetRef ref, Game game) async { - final syncService = ref.read(saveSyncServiceProvider); + final messenger = ScaffoldMessenger.of(context); + + final syncService = await ref.read(saveSyncServiceProvider.future); if (syncService == null) { - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( const SnackBar(content: Text('Save sync not available')), ); return; } - final dir = ref.read(directoryServiceProvider).asData?.value; final romPath = dir != null ? await dir.getRomFilePath(game) : ''; - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( SnackBar(content: Text('Syncing saves for ${game.name}...')), ); - final syncMode = ref.read(retroarchSyncModeProvider); final ok = await syncService.pushSaves(game, romPath, syncMode: syncMode); if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( SnackBar( content: Text(ok ? 'Saves uploaded for ${game.name}' @@ -251,63 +371,6 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> { ); } - void _startDownload(BuildContext context, WidgetRef ref, game) { - final service = ref.read(rommServiceProvider); - if (service == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Not connected to RomM')), - ); - return; - } - final url = service.getDownloadUrl(game); - final basicAuth = - 'Basic ${base64Encode(utf8.encode('${service.config.username}:${service.config.password}'))}'; - final headers = <String, String>{'Authorization': basicAuth}; - ref - .read(downloadProvider.notifier) - .startDownload(game, url, headers: headers); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Downloading ${game.name}...')), - ); - - final dirService = ref.read(directoryServiceProvider).asData?.value; - if (dirService != null) { - Future.delayed(const Duration(seconds: 2), () { - _refreshDownloadState(dirService, game); - }); - } - } - - double _calculateCardHeight(int columnCount, double cardSpacing, - double cardAspectRatio, BuildContext context) { - final screenWidth = MediaQuery.of(context).size.width; - const padding = 24.0; - final totalSpacing = cardSpacing * (columnCount - 1); - final cardWidth = (screenWidth - padding - totalSpacing) / columnCount; - final safeRatio = cardAspectRatio <= 0 ? 0.56 : cardAspectRatio; - final coverHeight = cardWidth / safeRatio; - final totalHeight = coverHeight + 90.0; - return totalHeight.clamp(100.0, 900.0); - } - - Widget _buildSkeletonGrid( - double cardAspectRatio, int columnCount, double cardSpacing) { - return GridView.builder( - padding: const EdgeInsets.all(12), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columnCount, - crossAxisSpacing: cardSpacing, - mainAxisSpacing: cardSpacing, - mainAxisExtent: _calculateCardHeight( - columnCount, cardSpacing, cardAspectRatio, context), - ), - itemCount: 20, - itemBuilder: (context, index) { - return _SkeletonCard(); - }, - ); - } - @override Widget build(BuildContext context) { final platformsAsync = ref.watch(platformsProvider); @@ -323,7 +386,6 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> { final rommConfigAsync = ref.watch(rommConfigProvider); final directoryServiceAsync = ref.watch(directoryServiceProvider); - // Build AppBar title: "Freegosy • hostname • N games" final appBarTitle = rommConfigAsync.when( data: (config) { final uri = Uri.tryParse(config.baseUrl); @@ -341,220 +403,157 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> { body: ExcludeSemantics( child: Column( children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: TextField( - decoration: InputDecoration( - hintText: 'Search games...', - prefixIcon: const Icon(Icons.search), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: TextField( + decoration: InputDecoration( + hintText: 'Search games...', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + ), ), + onChanged: (value) { + ref.read(searchQueryProvider.notifier).state = value; + }, ), - onChanged: (value) { - ref.read(searchQueryProvider.notifier).state = value; - }, ), - ), - platformsAsync.when( - data: (platforms) => PlatformFilterBar( - platforms: platforms, - selectedPlatformId: selectedPlatformId, - onSelected: (platform) { - ref.read(selectedPlatformIdProvider.notifier).state = platform?.id; - }, + platformsAsync.when( + data: (platforms) => PlatformFilterBar( + platforms: platforms, + selectedPlatformId: selectedPlatformId, + onSelected: (platform) { + ref.read(selectedPlatformIdProvider.notifier).state = platform?.id; + }, + ), + loading: () => const LinearProgressIndicator(), + error: (e, s) => Text('Error loading platforms: $e'), ), - loading: () => const LinearProgressIndicator(), - error: (e, s) => Text('Error loading platforms: $e'), - ), - Expanded( - child: gamesAsync.when( - loading: () => - _buildSkeletonGrid(cardAspectRatio, columnCount, cardSpacing), - error: (e, s) => Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - 'Error loading games: $e', - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.red), + Expanded( + child: gamesAsync.when( + loading: () => buildSkeletonGrid(cardAspectRatio, columnCount, cardSpacing, context), + error: (e, s) => Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'Error loading games: $e', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), ), ), - ), - data: (_) { - final gamesCount = filteredGames.length; - final countDisplayText = - (selectedPlatformId == null && searchQuery.isEmpty) - ? 'Showing all $gamesCount games' - : 'Showing $gamesCount games'; - - final dirService = directoryServiceAsync.asData?.value; - if (dirService != null && !_downloadStatesLoaded) { - final games = ref.read(allGamesProvider).asData?.value ?? []; - _loadDownloadStates(dirService, games); - } - - return Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 2.0), - child: Align( - alignment: Alignment.centerRight, - child: Text( - countDisplayText, - style: const TextStyle(fontSize: 12, color: Colors.grey), + data: (_) { + final gamesCount = filteredGames.length; + final countDisplayText = + (selectedPlatformId == null && searchQuery.isEmpty) + ? 'Showing all $gamesCount games' + : 'Showing $gamesCount games'; + + final dirService = directoryServiceAsync.asData?.value; + if (dirService != null && !_downloadStatesLoaded) { + final games = ref.read(allGamesProvider).asData?.value ?? []; + _loadDownloadStates(dirService, games); + } + + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 2.0), + child: Align( + alignment: Alignment.centerRight, + child: Text( + countDisplayText, + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), ), ), - ), - Expanded( - child: RefreshIndicator( - onRefresh: () async { - setState(() { - _downloadStatesLoaded = false; - _downloadedStates = {}; - }); - final prefs = await SharedPreferences.getInstance(); - await prefs.remove('cached_games'); - await prefs.remove('cached_platforms'); - await prefs.remove('cached_games_time'); - await prefs.remove('cached_platforms_time'); - await prefs.remove('cache_size_exceeded'); // Added this line - ref.invalidate(allGamesProvider); - ref.invalidate(platformsProvider); - await ref.read(allGamesProvider.future); - }, - child: filteredGames.isEmpty - ? const CustomScrollView( - slivers: [ - SliverFillRemaining( - child: - Center(child: Text('No games found')), + Expanded( + child: RefreshIndicator( + onRefresh: () async { + setState(() { + _downloadStatesLoaded = false; + _downloadedStates = {}; + }); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('cached_games'); + await prefs.remove('cached_platforms'); + await prefs.remove('cached_games_time'); + await prefs.remove('cached_platforms_time'); + await prefs.remove('cache_size_exceeded'); + ref.invalidate(allGamesProvider); + ref.invalidate(platformsProvider); + await ref.read(allGamesProvider.future); + }, + child: filteredGames.isEmpty + ? const CustomScrollView( + slivers: [ + SliverFillRemaining( + child: Center(child: Text('No games found')), + ), + ], + ) + : GridView.builder( + padding: const EdgeInsets.all(12), + cacheExtent: 800, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columnCount, + crossAxisSpacing: cardSpacing, + mainAxisSpacing: cardSpacing, + mainAxisExtent: calculateCardHeight( + columnCount, cardSpacing, cardAspectRatio, context), ), - ], - ) - : GridView.builder( - padding: const EdgeInsets.all(12), - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columnCount, - crossAxisSpacing: cardSpacing, - mainAxisSpacing: cardSpacing, - mainAxisExtent: _calculateCardHeight( - columnCount, - cardSpacing, - cardAspectRatio, - context), - ), - itemCount: filteredGames.length, - itemBuilder: (context, index) { - final game = filteredGames[index]; - final dirService = - directoryServiceAsync.asData?.value; - final isWindowsGame = [ - 'windows', - 'pc', - 'win' - ].contains( - game.platformSlug?.toLowerCase() ?? ''); - if (dirService == null) { + itemCount: filteredGames.length, + itemBuilder: (context, index) { + final game = filteredGames[index]; + final dirService = directoryServiceAsync.asData?.value; + final isWindowsGame = ['windows', 'pc', 'win'] + .contains(game.platformSlug?.toLowerCase() ?? ''); + final coverUrl = ref.read(rommServiceProvider)?.resolveCoverUrl(game); + + if (dirService == null) { + return GestureDetector( + onLongPress: isWindowsGame + ? () => _handleWindowsConfig(context, ref, game) + : null, + child: GameCard( + game: game, + coverUrl: coverUrl, + showTitle: showTitle, + showButtonsOnHover: showButtonsOnHover, + onDownload: () => _startDownload(context, ref, game), + onLaunch: () => _handleLaunch(context, ref, game), + onSyncSaves: () => _handleSyncSaves(context, ref, game), + ), + ); + } + return GestureDetector( onLongPress: isWindowsGame - ? () => _handleWindowsConfig( - context, ref, game) + ? () => _handleWindowsConfig(context, ref, game) : null, child: GameCard( game: game, + coverUrl: coverUrl, + isDownloaded: _downloadedStates[game.id] ?? false, showTitle: showTitle, showButtonsOnHover: showButtonsOnHover, - onDownload: () => - _startDownload(context, ref, game), - onLaunch: () => - _handleLaunch(context, ref, game), - onSyncSaves: () => - _handleSyncSaves(context, ref, game), + onDownload: () => _startDownload(context, ref, game), + onLaunch: () => _handleLaunch(context, ref, game), + onSyncSaves: () => _handleSyncSaves(context, ref, game), ), ); - } - - return GestureDetector( - onLongPress: isWindowsGame - ? () => _handleWindowsConfig( - context, ref, game) - : null, - child: GameCard( - game: game, - isDownloaded: _downloadedStates[game.id] ?? false, - showTitle: showTitle, - showButtonsOnHover: showButtonsOnHover, - onDownload: () => _startDownload( - context, ref, game), - onLaunch: () => - _handleLaunch(context, ref, game), - onSyncSaves: () => _handleSyncSaves( - context, ref, game), - ), - ); - }, - ), + }, + ), + ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), - ), - ], - ), + ], + ), ), ); } -} - -class _SkeletonCard extends StatefulWidget { - @override - State<_SkeletonCard> createState() => _SkeletonCardState(); -} - -class _SkeletonCardState extends State<_SkeletonCard> - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation<double> _animation; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1200), - )..repeat(reverse: true); - _animation = CurvedAnimation( - parent: _controller, - curve: Curves.easeInOut, - ); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _animation, - builder: (context, child) { - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Color.lerp( - const Color(0xFF1a1a1a), - const Color(0xFF2a2a2a), - _animation.value, - ), - ), - ); - }, - ); - } -} +} \ No newline at end of file diff --git a/lib/ui/screens/library_skeleton.dart b/lib/ui/screens/library_skeleton.dart new file mode 100644 index 0000000..45840c9 --- /dev/null +++ b/lib/ui/screens/library_skeleton.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +double calculateCardHeight(int columnCount, double cardSpacing, + double cardAspectRatio, BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + const padding = 24.0; + final totalSpacing = cardSpacing * (columnCount - 1); + final cardWidth = (screenWidth - padding - totalSpacing) / columnCount; + final safeRatio = cardAspectRatio <= 0 ? 0.56 : cardAspectRatio; + final coverHeight = cardWidth / safeRatio; + final totalHeight = coverHeight + 90.0; + return totalHeight.clamp(100.0, 900.0); +} + +Widget buildSkeletonGrid( + double cardAspectRatio, int columnCount, double cardSpacing, BuildContext context) { + return GridView.builder( + padding: const EdgeInsets.all(12), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columnCount, + crossAxisSpacing: cardSpacing, + mainAxisSpacing: cardSpacing, + mainAxisExtent: calculateCardHeight(columnCount, cardSpacing, cardAspectRatio, context), + ), + itemCount: 20, + itemBuilder: (context, index) { + return const _SkeletonCard(); + }, + ); +} + +class _SkeletonCard extends StatefulWidget { + const _SkeletonCard(); + + @override + State<_SkeletonCard> createState() => _SkeletonCardState(); +} + +class _SkeletonCardState extends State<_SkeletonCard> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation<double> _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + ); + _animation = CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (TickerMode.valuesOf(context).enabled) { + _controller.repeat(reverse: true); + } else { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Color.lerp( + const Color(0xFF1a1a1a), + const Color(0xFF2a2a2a), + _animation.value, + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/ui/screens/settings_display_section.dart b/lib/ui/screens/settings_display_section.dart new file mode 100644 index 0000000..ffd9574 --- /dev/null +++ b/lib/ui/screens/settings_display_section.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../providers/library_provider.dart'; // Assuming this file contains the providers and kDisplayPresets + +// NOTE: kDisplayPresets is assumed to be globally available or defined in library_provider.dart +// If not, it needs to be imported or passed as an argument. + +// Function to build the Library Display section +Widget buildDisplaySection( + BuildContext context, + double cardAspectRatio, + int columnCount, + double cardSpacing, + bool showTitle, + bool showButtonsOnHover, + String activePreset, + WidgetRef ref, +) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Library Display', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + const Text('Presets', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + _presetChip('Windows', 'windows_best', activePreset, ref), + _presetChip('Steam Deck', 'steamdeck_best', activePreset, ref), + _presetChip('Cozy', 'cozy', activePreset, ref), + _presetChip('Compact', 'compact', activePreset, ref), + _presetChip('Custom', 'custom', activePreset, ref), + ], + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Columns per row', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + Text('$columnCount', style: const TextStyle(fontSize: 16, color: Colors.deepPurple)), + ], + ), + Slider( + value: columnCount.toDouble(), + min: 2, + max: 8, + divisions: 6, + label: '$columnCount', + onChanged: (value) async { + ref.read(activePresetProvider.notifier).state = 'custom'; + ref.read(columnCountProvider.notifier).state = value.toInt(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('column_count', value.toInt()); + await prefs.setString('active_preset', 'custom'); + }, + ), + const SizedBox(height: 16), + const Text('Card Shape', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + SegmentedButton<double>( + segments: const [ + ButtonSegment(value: 1.0, label: Text('Square')), + ButtonSegment(value: 0.72, label: Text('Portrait')), + ButtonSegment(value: 0.58, label: Text('Tall')), + ], + selected: { + [1.0, 0.72, 0.58].reduce((a, b) => + (a - cardAspectRatio).abs() < (b - cardAspectRatio).abs() + ? a + : b) + }, + onSelectionChanged: (selection) async { + ref.read(activePresetProvider.notifier).state = 'custom'; + ref.read(cardAspectRatioProvider.notifier).state = selection.first; + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble('card_aspect_ratio', selection.first); + await prefs.setString('active_preset', 'custom'); + }, + ), + const SizedBox(height: 16), + const Text('Card Spacing', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + SegmentedButton<double>( + segments: const [ + ButtonSegment(value: 4.0, label: Text('Tight')), + ButtonSegment(value: 8.0, label: Text('Normal')), + ButtonSegment(value: 12.0, label: Text('Airy')), + ], + selected: { + [4.0, 8.0, 12.0].reduce((a, b) => + (a - cardSpacing).abs() < (b - cardSpacing).abs() ? a : b) + }, + onSelectionChanged: (selection) async { + ref.read(activePresetProvider.notifier).state = 'custom'; + ref.read(cardSpacingProvider.notifier).state = selection.first; + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble('card_spacing', selection.first); + await prefs.setString('active_preset', 'custom'); + }, + ), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('Show game title'), + subtitle: const Text('Display title text below cover art'), + value: showTitle, + contentPadding: EdgeInsets.zero, + onChanged: (value) async { + ref.read(activePresetProvider.notifier).state = 'custom'; + ref.read(showTitleProvider.notifier).state = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('show_title', value); + await prefs.setString('active_preset', 'custom'); + }, + ), + SwitchListTile( + title: const Text('Show buttons on hover only'), + subtitle: const Text('Buttons appear when hovering over a card'), + value: showButtonsOnHover, + contentPadding: EdgeInsets.zero, + onChanged: (value) async { + ref.read(activePresetProvider.notifier).state = 'custom'; + ref.read(showButtonsOnHoverProvider.notifier).state = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('show_buttons_on_hover', value); + await prefs.setString('active_preset', 'custom'); + }, + ), + ], + ); +} + +// Helper widget for preset chips +Widget _presetChip(String label, String presetKey, String activePreset, WidgetRef ref) { + final isSelected = activePreset == presetKey; + return FilterChip( + label: Text(label), + selected: isSelected, + onSelected: (selected) async { + if (!selected) return; + ref.read(activePresetProvider.notifier).state = presetKey; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('active_preset', presetKey); + if (presetKey == 'custom') return; + final preset = kDisplayPresets[presetKey]; // kDisplayPresets must be accessible + if (preset == null) return; + final cols = preset['columnCount'] as int; + final ratio = preset['cardAspectRatio'] as double; + final spacing = preset['cardSpacing'] as double; + final title = preset['showTitle'] as bool; + final hover = preset['showButtonsOnHover'] as bool; + ref.read(columnCountProvider.notifier).state = cols; + ref.read(cardAspectRatioProvider.notifier).state = ratio; + ref.read(cardSpacingProvider.notifier).state = spacing; + ref.read(showTitleProvider.notifier).state = title; + ref.read(showButtonsOnHoverProvider.notifier).state = hover; + await prefs.setInt('column_count', cols); + await prefs.setDouble('card_aspect_ratio', ratio); + await prefs.setDouble('card_spacing', spacing); + await prefs.setBool('show_title', title); + await prefs.setBool('show_buttons_on_hover', hover); + }, + ); +} diff --git a/lib/ui/screens/settings_emulators_section.dart b/lib/ui/screens/settings_emulators_section.dart new file mode 100644 index 0000000..4fc673a --- /dev/null +++ b/lib/ui/screens/settings_emulators_section.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:file_picker/file_picker.dart'; +import '../../core/storage/directory_service.dart'; +import '../../core/emulator/emulator_registry_data.dart'; +import '../../core/emulator/strategy_registry.dart'; +import '../../providers/download_provider.dart'; + +// Function to build the Emulators section +Widget buildEmulatorsSection( + BuildContext context, + DirectoryService directoryService, + bool emulatorsLoaded, + Map<String, bool> emulatorInstallStates, + Function(void Function()) setState, + WidgetRef ref, +) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Emulators', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + if (!emulatorsLoaded) + const Center(child: CircularProgressIndicator()) + else + ...kEmulatorDefinitions.map<Widget>((def) { + final emulatorId = def['id'] as String; + final emulatorName = def['name'] as String; + final isInstalled = emulatorInstallStates[emulatorId] ?? false; + final overridePath = directoryService.getEmulatorPathOverride(emulatorId); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Icon( + isInstalled ? Icons.check_circle : Icons.cancel, + color: isInstalled ? Colors.green : Colors.red, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(emulatorName), + if (overridePath != null) + Text( + overridePath, + style: const TextStyle(fontSize: 11, color: Colors.grey), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.folder_open, size: 20), + tooltip: 'Set custom directory', + onPressed: () async { + String? selectedDirectory = await FilePicker.platform.getDirectoryPath(); + if (selectedDirectory != null) { + await directoryService.setEmulatorPathOverride(emulatorId, selectedDirectory); + setState(() {}); // Trigger parent state update + } + }, + ), + ElevatedButton( + onPressed: isInstalled + ? null + : () async { + // Capture context safely before any async operations. + final messenger = ScaffoldMessenger.of(context); + + messenger.showSnackBar(SnackBar( + content: Text('Starting download for $emulatorName...'), + )); + + ref.read(downloadProvider.notifier).startEmulatorDownload(emulatorId, emulatorName); + + // Listen for download completion and update state + ref.read(downloadProvider.notifier).stream.listen((downloads) { + final progress = downloads[emulatorId]; + if (progress != null && progress.isComplete) { // Use safeContext here + // Update the map directly. setState will re-render using the updated map. + emulatorInstallStates[emulatorId] = true; + setState(() {}); // Trigger parent state update + messenger.showSnackBar(SnackBar( + content: Text('$emulatorName downloaded.'), + )); + } + }); + }, + child: Text(isInstalled ? 'Installed' : 'Download'), + ), + ], + ), + ); + }), + ], + ); +} + +// Function to build the Emulator Conflicts section +Widget buildConflictsSection( + StrategyRegistry registry, + Function(void Function()) setState, +) { + final conflicts = registry.detectConflicts(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Emulator Conflicts', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + if (conflicts.isEmpty) + const Text('No conflicts detected') + else + ...conflicts.entries.map((entry) { + final slug = entry.key; + final strategies = entry.value; + final currentStrategy = registry.getStrategyForSlug(slug); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(slug, style: const TextStyle(fontWeight: FontWeight.w500)), + Text(strategies.map((s) => s.name).join(' vs '), + style: const TextStyle(fontSize: 12, color: Colors.grey)), + ], + ), + ), + DropdownButton<String>( + value: currentStrategy?.emulatorId, + items: strategies.map((s) { + return DropdownMenuItem( + value: s.emulatorId, + child: Text(s.name), + ); + }).toList(), + onChanged: (value) async { + if (value != null) { + await registry.setPreference(slug, value); + setState(() {}); // Trigger parent state update + } + }, + ), + ], + ), + ); + }), + ], + ); +} diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index 55c92dd..74fddff 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -1,15 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; -import 'package:dio/dio.dart'; import 'package:shared_preferences/shared_preferences.dart'; - import '../../core/storage/directory_service.dart'; import '../../core/emulator/emulator_registry_data.dart'; -import '../../core/emulator/emulator_download_service.dart'; +// import '../../core/extraction/extraction_service.dart'; // Removed unused import import '../../providers/romm_provider.dart'; -import '../../providers/library_provider.dart'; +import '../../providers/library_provider.dart'; // Assuming this file contains the display providers and kDisplayPresets import '../../core/romm/romm_service.dart'; +import 'settings_emulators_section.dart'; +import 'settings_display_section.dart'; class SettingsScreen extends ConsumerStatefulWidget { const SettingsScreen({super.key}); @@ -24,7 +24,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { late TextEditingController _passwordController; bool _isSaving = false; Map<String, bool> _emulatorInstallStates = {}; - bool _emulatorsLoaded = false; + bool _emulatorsLoaded = false; // This state is managed here + bool _preferencesLoaded = false; @override void initState() { @@ -32,6 +33,14 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { _baseUrlController = TextEditingController(); _usernameController = TextEditingController(); _passwordController = TextEditingController(); + + // Mark preferences as loaded (they are now awaited by the provider) + WidgetsBinding.instance.addPostFrameCallback((_) { + final registry = ref.read(strategyRegistryProvider).asData?.value; + if (registry != null && !_preferencesLoaded) { + if (mounted) setState(() => _preferencesLoaded = true); + } + }); } @override @@ -42,6 +51,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { super.dispose(); } + // This method should remain in the state class to manage its own state. Future<void> _loadEmulatorStates(DirectoryService directoryService) async { if (_emulatorsLoaded) return; final states = <String, bool>{}; @@ -49,9 +59,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { final id = def['id'] as String; final exe = def['windows_executable'] as String; if (exe.isEmpty) { - states[id] = true; + states[id] = true; // Assume installed if no executable is defined continue; } + // Check if emulator is installed using the directory service states[id] = await directoryService.isEmulatorInstalled(id, exe); } if (mounted) { @@ -67,7 +78,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { final directoryServiceAsync = ref.watch(directoryServiceProvider); final rommService = ref.watch(rommServiceProvider); final rommConfigAsync = ref.watch(rommConfigProvider); + final strategyRegistry = ref.watch(strategyRegistryProvider).asData?.value; + // Display section providers final cardAspectRatio = ref.watch(cardAspectRatioProvider); final columnCount = ref.watch(columnCountProvider); final cardSpacing = ref.watch(cardSpacingProvider); @@ -75,10 +88,16 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { final showButtonsOnHover = ref.watch(showButtonsOnHoverProvider); final activePreset = ref.watch(activePresetProvider); + // Mark preferences as loaded (they are awaited by the provider) + if (strategyRegistry != null && !_preferencesLoaded) { + if (mounted) setState(() => _preferencesLoaded = true); + } + return Scaffold( appBar: AppBar(title: const Text('Settings')), body: rommConfigAsync.when( data: (rommConfig) { + // Update controllers with loaded config _baseUrlController.text = rommConfig.baseUrl; _usernameController.text = rommConfig.username; _passwordController.text = rommConfig.password; @@ -92,6 +111,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { return const Center(child: CircularProgressIndicator()); } + // Load emulator states if directory service is available _loadEmulatorStates(directoryService); return ExcludeSemantics( @@ -100,7 +120,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { children: [ _buildRommServerSection(context, ref, rommService), const SizedBox(height: 24), - _buildDisplaySection( + // Call the extracted display section function + buildDisplaySection( context, cardAspectRatio, columnCount, @@ -108,13 +129,30 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { showTitle, showButtonsOnHover, activePreset, + ref, // Pass ref ), const SizedBox(height: 24), _buildStorageSection(directoryService), const SizedBox(height: 24), _buildRetroArchSyncModeSection(context, ref), const SizedBox(height: 24), - _buildEmulatorsSection(directoryService), + // Call the extracted emulators section function + buildEmulatorsSection( + context, // Pass context + directoryService, + _emulatorsLoaded, // Pass loaded state + _emulatorInstallStates, // Pass the states map + setState, // Pass the setState callback + ref, // Pass ref + ), + if (strategyRegistry != null) ...[ + const SizedBox(height: 24), + // Call the extracted conflicts section function + buildConflictsSection( + strategyRegistry, + setState, // Pass the setState callback + ), + ], ], ), ); @@ -129,188 +167,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { ); } - Widget _buildDisplaySection( - BuildContext context, - double cardAspectRatio, - int columnCount, - double cardSpacing, - bool showTitle, - bool showButtonsOnHover, - String activePreset, - ) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Library Display', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - const Text('Presets', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - _presetChip('Windows', 'windows_best', activePreset), - _presetChip('Steam Deck', 'steamdeck_best', activePreset), - _presetChip('Cozy', 'cozy', activePreset), - _presetChip('Compact', 'compact', activePreset), - _presetChip('Custom', 'custom', activePreset), - ], - ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Columns per row', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), - Text('$columnCount', style: const TextStyle(fontSize: 16, color: Colors.deepPurple)), - ], - ), - Slider( - value: columnCount.toDouble(), - min: 2, - max: 8, - divisions: 6, - label: '$columnCount', - onChanged: (value) async { - ref.read(activePresetProvider.notifier).state = 'custom'; - ref.read(columnCountProvider.notifier).state = value.toInt(); - final prefs = await SharedPreferences.getInstance(); - await prefs.setInt('column_count', value.toInt()); - await prefs.setString('active_preset', 'custom'); - }, - ), - const SizedBox(height: 16), - const Text('Card Shape', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), - const SizedBox(height: 8), - SegmentedButton<double>( - segments: const [ - ButtonSegment(value: 1.0, label: Text('Square')), - ButtonSegment(value: 0.72, label: Text('Portrait')), - ButtonSegment(value: 0.58, label: Text('Tall')), - ], - selected: { - [1.0, 0.72, 0.58].reduce((a, b) => - (a - cardAspectRatio).abs() < (b - cardAspectRatio).abs() - ? a - : b) - }, - onSelectionChanged: (selection) async { - ref.read(activePresetProvider.notifier).state = 'custom'; - ref.read(cardAspectRatioProvider.notifier).state = selection.first; - final prefs = await SharedPreferences.getInstance(); - await prefs.setDouble('card_aspect_ratio', selection.first); - await prefs.setString('active_preset', 'custom'); - }, - ), - const SizedBox(height: 16), - const Text('Card Spacing', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), - const SizedBox(height: 8), - SegmentedButton<double>( - segments: const [ - ButtonSegment(value: 4.0, label: Text('Tight')), - ButtonSegment(value: 8.0, label: Text('Normal')), - ButtonSegment(value: 12.0, label: Text('Airy')), - ], - selected: { - [4.0, 8.0, 12.0].reduce((a, b) => - (a - cardSpacing).abs() < (b - cardSpacing).abs() ? a : b) - }, - onSelectionChanged: (selection) async { - ref.read(activePresetProvider.notifier).state = 'custom'; - ref.read(cardSpacingProvider.notifier).state = selection.first; - final prefs = await SharedPreferences.getInstance(); - await prefs.setDouble('card_spacing', selection.first); - await prefs.setString('active_preset', 'custom'); - }, - ), - const SizedBox(height: 16), - SwitchListTile( - title: const Text('Show game title'), - subtitle: const Text('Display title text below cover art'), - value: showTitle, - contentPadding: EdgeInsets.zero, - onChanged: (value) async { - ref.read(activePresetProvider.notifier).state = 'custom'; - ref.read(showTitleProvider.notifier).state = value; - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('show_title', value); - await prefs.setString('active_preset', 'custom'); - }, - ), - SwitchListTile( - title: const Text('Show buttons on hover only'), - subtitle: const Text('Buttons appear when hovering over a card'), - value: showButtonsOnHover, - contentPadding: EdgeInsets.zero, - onChanged: (value) async { - ref.read(activePresetProvider.notifier).state = 'custom'; - ref.read(showButtonsOnHoverProvider.notifier).state = value; - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('show_buttons_on_hover', value); - await prefs.setString('active_preset', 'custom'); - }, - ), - ], - ); - } - - Widget _presetChip(String label, String presetKey, String activePreset) { - final isSelected = activePreset == presetKey; - return FilterChip( - label: Text(label), - selected: isSelected, - onSelected: (selected) async { - if (!selected) return; - ref.read(activePresetProvider.notifier).state = presetKey; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('active_preset', presetKey); - if (presetKey == 'custom') return; - final preset = kDisplayPresets[presetKey]; - if (preset == null) return; - final cols = preset['columnCount'] as int; - final ratio = preset['cardAspectRatio'] as double; - final spacing = preset['cardSpacing'] as double; - final title = preset['showTitle'] as bool; - final hover = preset['showButtonsOnHover'] as bool; - ref.read(columnCountProvider.notifier).state = cols; - ref.read(cardAspectRatioProvider.notifier).state = ratio; - ref.read(cardSpacingProvider.notifier).state = spacing; - ref.read(showTitleProvider.notifier).state = title; - ref.read(showButtonsOnHoverProvider.notifier).state = hover; - await prefs.setInt('column_count', cols); - await prefs.setDouble('card_aspect_ratio', ratio); - await prefs.setDouble('card_spacing', spacing); - await prefs.setBool('show_title', title); - await prefs.setBool('show_buttons_on_hover', hover); - }, - ); - } - - Widget _buildRetroArchSyncModeSection(BuildContext context, WidgetRef ref) { - final syncMode = ref.watch(retroarchSyncModeProvider); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('RetroArch Save Sync', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - const Text('What to sync with RomM cloud', style: TextStyle(color: Colors.grey)), - const SizedBox(height: 12), - SegmentedButton<String>( - segments: const [ - ButtonSegment(value: 'saves', label: Text('Saves only')), - ButtonSegment(value: 'states', label: Text('States only')), - ButtonSegment(value: 'both', label: Text('Both')), - ], - selected: {syncMode}, - onSelectionChanged: (selection) async { - final value = selection.first; - ref.read(retroarchSyncModeProvider.notifier).state = value; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('retroarch_sync_mode', value); - }, - ), - ], - ); - } - + // --- RomM Server Section --- Widget _buildRommServerSection(BuildContext context, WidgetRef ref, rommService) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -358,6 +215,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { return; } try { + // Test connection by fetching platforms final platforms = await rommService.getPlatforms(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -400,9 +258,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { await prefs.setString('rommUsername', username); await prefs.setString('rommPassword', password); - // ignore: unused_result + // Invalidate providers to refresh RomM service and config ref.invalidate(rommConfigProvider); - // ignore: unused_result ref.invalidate(rommServiceProvider); if (context.mounted) { @@ -422,6 +279,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { ); } + // --- Storage Section --- Widget _buildStorageSection(DirectoryService directoryService) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -434,8 +292,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { onChanged: (newPath) async { if (newPath != null) { await directoryService.setRomsRoot(newPath); - // ignore: unused_result - ref.refresh(directoryServiceProvider); + // Fix: Replace ref.refresh with ref.invalidate + ref.invalidate(directoryServiceProvider); } }, ), @@ -446,8 +304,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { onChanged: (newPath) async { if (newPath != null) { await directoryService.setEmulatorsRoot(newPath); - // ignore: unused_result - ref.refresh(directoryServiceProvider); + // Fix: Replace ref.refresh with ref.invalidate + ref.invalidate(directoryServiceProvider); } }, ), @@ -484,82 +342,30 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { ); } - Widget _buildEmulatorsSection(DirectoryService directoryService) { + // --- RetroArch Sync Mode Section --- + Widget _buildRetroArchSyncModeSection(BuildContext context, WidgetRef ref) { + final syncMode = ref.watch(retroarchSyncModeProvider); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Emulators', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - const SizedBox(height: 16), - if (!_emulatorsLoaded) - const Center(child: CircularProgressIndicator()) - else - ...kEmulatorDefinitions.map<Widget>((def) { - final emulatorId = def['id'] as String; - final emulatorName = def['name'] as String; - final isInstalled = _emulatorInstallStates[emulatorId] ?? false; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Row( - children: [ - Icon( - isInstalled ? Icons.check_circle : Icons.cancel, - color: isInstalled ? Colors.green : Colors.red, - ), - const SizedBox(width: 8), - Expanded(child: Text(emulatorName)), - ElevatedButton( - onPressed: isInstalled - ? null - : () async { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: - Text('Starting download for $emulatorName...'), - )); - final emulatorDownloadService = - EmulatorDownloadService( - Dio(), directoryService); - try { - await for (final progress in emulatorDownloadService - .downloadEmulator(emulatorId)) { - if (progress.error != null) { - if (mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar( - content: Text('Error: ${progress.error}'), - )); - } - break; - } - if (progress.isComplete) { - if (mounted) { - setState(() { - _emulatorInstallStates[emulatorId] = true; - }); - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar( - content: - Text('$emulatorName downloaded.'), - )); - } - break; - } - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar( - content: Text('Unexpected error: $e'), - )); - } - } - }, - child: Text(isInstalled ? 'Installed' : 'Download'), - ), - ], - ), - ); - }), + const Text('RetroArch Save Sync', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text('What to sync with RomM cloud', style: TextStyle(color: Colors.grey)), + const SizedBox(height: 12), + SegmentedButton<String>( + segments: const [ + ButtonSegment(value: 'saves', label: Text('Saves only')), + ButtonSegment(value: 'states', label: Text('States only')), + ButtonSegment(value: 'both', label: Text('Both')), + ], + selected: {syncMode}, + onSelectionChanged: (selection) async { + final value = selection.first; + ref.read(retroarchSyncModeProvider.notifier).state = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('retroarch_sync_mode', value); + }, + ), ], ); } diff --git a/lib/ui/widgets/game_card.dart b/lib/ui/widgets/game_card.dart index e41b224..cda7d41 100644 --- a/lib/ui/widgets/game_card.dart +++ b/lib/ui/widgets/game_card.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import '../../core/romm/romm_models.dart'; -import '../../providers/romm_provider.dart'; -class GameCard extends ConsumerStatefulWidget { +class GameCard extends StatefulWidget { final Game game; + final String? coverUrl; final VoidCallback onDownload; final VoidCallback onLaunch; final VoidCallback? onSyncSaves; @@ -15,6 +15,7 @@ class GameCard extends ConsumerStatefulWidget { const GameCard({ super.key, required this.game, + this.coverUrl, required this.onDownload, required this.onLaunch, this.onSyncSaves, @@ -24,23 +25,26 @@ class GameCard extends ConsumerStatefulWidget { }); @override - ConsumerState<GameCard> createState() => _GameCardState(); + State<GameCard> createState() => _GameCardState(); } -class _GameCardState extends ConsumerState<GameCard> { - bool _isHovering = false; +class _GameCardState extends State<GameCard> { + final ValueNotifier<bool> _hovering = ValueNotifier(false); @override - Widget build(BuildContext context) { - final service = ref.watch(rommServiceProvider); - final finalCoverUrl = service?.resolveCoverUrl(widget.game); + void dispose() { + _hovering.dispose(); + super.dispose(); + } + @override + Widget build(BuildContext context) { return RepaintBoundary( child: GestureDetector( onLongPress: () => _showContextMenu(context), child: MouseRegion( - onEnter: (_) => setState(() => _isHovering = true), - onExit: (_) => setState(() => _isHovering = false), + onEnter: (_) => _hovering.value = true, + onExit: (_) => _hovering.value = false, child: Card( elevation: 2, clipBehavior: Clip.antiAlias, @@ -53,13 +57,21 @@ class _GameCardState extends ConsumerState<GameCard> { child: Stack( fit: StackFit.expand, children: [ - (finalCoverUrl == null || finalCoverUrl.isEmpty) + (widget.coverUrl == null || widget.coverUrl!.isEmpty) ? const Center(child: Icon(Icons.sports_esports, size: 48)) - : Image.network( - finalCoverUrl, + : CachedNetworkImage( + imageUrl: widget.coverUrl!, fit: BoxFit.cover, alignment: Alignment.topCenter, - errorBuilder: (context, error, stackTrace) => const Center( + memCacheWidth: 300, + memCacheHeight: 400, + placeholder: (context, url) => Container( + color: Colors.grey[300], + child: const Center( + child: Icon(Icons.image, color: Colors.grey), + ), + ), + errorWidget: (context, url, error) => const Center( child: Icon(Icons.sports_esports, size: 48), ), ), @@ -81,74 +93,79 @@ class _GameCardState extends ConsumerState<GameCard> { ), ), // Content - fixed height - SizedBox( - height: 90, - child: SingleChildScrollView( - physics: const NeverScrollableScrollPhysics(), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (!widget.showButtonsOnHover || !_isHovering) - if (widget.showTitle) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 1.0), - child: Text( - widget.game.displayName, - maxLines: 2, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold), - ), - ) - else - const Padding( - padding: EdgeInsets.symmetric(vertical: 1.0), - child: Center( - child: Icon(Icons.more_horiz, size: 16, color: Colors.grey), - ), - ), - - if (!widget.showButtonsOnHover || _isHovering) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - visualDensity: VisualDensity.compact, - iconSize: 22, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - icon: const Icon(Icons.download), - onPressed: widget.onDownload, - tooltip: 'Download', + ValueListenableBuilder<bool>( + valueListenable: _hovering, + builder: (context, isHovering, child) { + return SizedBox( + height: 90, + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (!widget.showButtonsOnHover || !isHovering) + if (widget.showTitle) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 1.0), + child: Text( + widget.game.displayName, + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold), + ), + ) + else + const Padding( + padding: EdgeInsets.symmetric(vertical: 1.0), + child: Center( + child: Icon(Icons.more_horiz, size: 16, color: Colors.grey), + ), ), - IconButton( - visualDensity: VisualDensity.compact, - iconSize: 22, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - icon: const Icon(Icons.play_arrow), - onPressed: widget.onLaunch, - tooltip: 'Launch', + + if (!widget.showButtonsOnHover || isHovering) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + visualDensity: VisualDensity.compact, + iconSize: 22, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + icon: const Icon(Icons.download), + onPressed: widget.onDownload, + tooltip: 'Download', + ), + IconButton( + visualDensity: VisualDensity.compact, + iconSize: 22, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + icon: const Icon(Icons.play_arrow), + onPressed: widget.onLaunch, + tooltip: 'Launch', + ), + if (widget.onSyncSaves != null) + IconButton( + visualDensity: VisualDensity.compact, + iconSize: 18, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + icon: const Icon(Icons.cloud_upload), + onPressed: widget.onSyncSaves, + tooltip: 'Sync saves', + ), + ], ), - if (widget.onSyncSaves != null) - IconButton( - visualDensity: VisualDensity.compact, - iconSize: 18, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - icon: const Icon(Icons.cloud_upload), - onPressed: widget.onSyncSaves, - tooltip: 'Sync saves', - ), - ], - ), - ), - ], - ), - ), + ), + ], + ), + ), + ); + } ), ], ), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 55e04aa..42545f9 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,8 +7,10 @@ import Foundation import package_info_plus import shared_preferences_foundation +import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 5018c6d..7a95e6d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "4a5d8d2c728b0f3d0245f69f921d7be90cae4c2fd5288f773088672c0893f819" + url: "https://pub.dev" + source: hosted + version: "3.4.0" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: ff0c949e323d2a1b52be73acce5b4a7b04063e61414c8ca542dbba47281630a7 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "6322dde7a5ad92202e64df659241104a43db20ed594c41ca18de1014598d7996" + url: "https://pub.dev" + source: hosted + version: "1.3.0" characters: dependency: transitive description: @@ -129,11 +153,27 @@ packages: url: "https://pub.dev" source: hosted version: "8.0.0+1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: a77f77806a790eb9ba0118a5a3a936e81c4fea2b61533033b2b0c3d50bbde5ea + url: "https://pub.dev" + source: hosted + version: "3.4.0" flutter_lints: dependency: "direct dev" description: @@ -288,6 +328,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_info_plus: dependency: "direct main" description: @@ -400,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" shared_preferences: dependency: "direct main" description: @@ -469,6 +525,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -501,6 +597,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: @@ -525,6 +629,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ebbfe54..8bc4258 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: path_provider: ^2.1.2 shared_preferences: ^2.2.2 package_info_plus: ^5.0.1 + cached_network_image: ^3.3.1 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons.