Skip to content

commit 1c36b87

abduznik edited this page May 23, 2026 · 1 revision

feat: paginated game loading with server-side platform filtering (#7)

Commit: 1c36b876fc1d9dadf38a161a035e01b6503acc73

Author: abduznik

Date: 2026-03-28

Message

  • fix: universal save sync with hash-based deduplication
  • Dolphin save sync now filters GCI files by game name match instead of uploading entire memory card
  • Hash comparison before every upload skips unchanged saves using MD5 stored in SharedPreferences
  • Works for all file types including fixed-size PS1 PS2 and Xbox memory cards where file size never changes
  • Pull sync uses remote updated_at timestamp to skip unnecessary downloads
  • feat: paginated game loading with server-side platform filtering
  • Add getGamesPage() to RommService using RomM 4.x platform_ids param
  • Add PaginatedGamesNotifier with per-key in-memory cache
  • Wire pagination into LibraryScreen with scroll-triggered loadMore
  • Platform switching triggers server-side filtered page fetch
  • Search triggers server-side filtered page fetch
  • Extract paginated_games_provider.dart as standalone file
  • fix: search debounce and F5 refresh animation
  • Debounce search input 300ms to avoid per-keystroke API calls
  • Fix _refreshLibrary to use paginated notifier instead of old allGamesProvider
  • F5 refresh now correctly shows loading animation and clears cache
  • fix: make _downloadedStates final per lint suggestion

Why: Adds a new feature or capability to the application.

Files Changed

lib/core/romm/romm_service.dart             |  30 +++
 lib/core/save/save_sync_service.dart        | 133 ++++++++----
 lib/providers/paginated_games_provider.dart | 174 +++++++++++++++
 lib/ui/screens/library_screen.dart          | 314 ++++++++++++++--------------
 pubspec.lock                                |   2 +-
 pubspec.yaml                                |   1 +
 6 files changed, 458 insertions(+), 196 deletions(-)
  • lib/core/romm/romm_service.dart
  • lib/core/save/save_sync_service.dart
  • lib/providers/paginated_games_provider.dart
  • lib/ui/screens/library_screen.dart
  • pubspec.lock
  • pubspec.yaml

Diff

diff --git a/lib/core/romm/romm_service.dart b/lib/core/romm/romm_service.dart
index bd6be2a..c268d2d 100644
--- a/lib/core/romm/romm_service.dart
+++ b/lib/core/romm/romm_service.dart
@@ -45,6 +45,11 @@ class RommService {
         handler.next(e);
       },
     ));
+    _dio.interceptors.add(InterceptorsWrapper(
+      onRequest: (RequestOptions options, RequestInterceptorHandler handler) {
+        handler.next(options);
+      },
+    ));
   }
 
   /// Returns the appropriate auth Options for each request.
@@ -136,6 +141,31 @@ class RommService {
     return _fetchPaginatedGames(params);
   }
 
+  Future<({List<Game> games, int total})> getGamesPage({
+    int offset = 0,
+    int limit = 50,
+    String? platformId,
+    String? search,
+  }) async {
+    final params = <String, dynamic>{
+      'limit': limit,
+      'offset': offset,
+    };
+    params['order_by'] = 'name';
+    params['order_dir'] = 'asc';
+    if (platformId != null) params['platform_ids'] = [int.parse(platformId)];
+    if (search != null && search.isNotEmpty) params['search_term'] = search;
+
+    final response = await _dio.get('/api/roms', queryParameters: params, options: _authOptions);
+    if (response.statusCode == 200) {
+      final Map<String, dynamic> data = response.data is Map ? response.data : {'items': response.data};
+      final List<dynamic> items = data['items'] ?? [];
+      final int total = data['total'] ?? items.length;
+      return (games: items.map((e) => Game.fromJson(e)).toList(), total: total);
+    }
+    throw DioException(requestOptions: response.requestOptions, response: response, type: DioExceptionType.badResponse);
+  }
+
   Future<List<Game>> _fetchPaginatedGames(Map<String, dynamic> params) async {
     int offset = 0;
     const int limit = 100;
diff --git a/lib/core/save/save_sync_service.dart b/lib/core/save/save_sync_service.dart
index 4b3139a..fd7b969 100644
--- a/lib/core/save/save_sync_service.dart
+++ b/lib/core/save/save_sync_service.dart
@@ -1,4 +1,6 @@
 import 'dart:io';
+import 'package:crypto/crypto.dart';
+import 'package:shared_preferences/shared_preferences.dart';
 import '../romm/romm_models.dart';
 import '../romm/romm_service.dart';
 import '../storage/directory_service.dart';
@@ -145,28 +147,66 @@ class SaveSyncService {
     }
   }
 
+  String _hashKey(String gameId, String filename) =>
+      'last_hash_${gameId}_$filename';
+
+  Future<String?> _getStoredHash(
+      String gameId, String filename) async {
+    final prefs = await SharedPreferences.getInstance();
+    return prefs.getString(_hashKey(gameId, filename));
+  }
+
+  Future<void> _storeHash(
+      String gameId, String filename, String hash) async {
+    final prefs = await SharedPreferences.getInstance();
+    await prefs.setString(_hashKey(gameId, filename), hash);
+  }
+
+  Future<String> _hashFile(File file) async {
+    final bytes = await file.readAsBytes();
+    return md5.convert(bytes).toString();
+  }
+
+  String _pullKey(String gameId) =>
+      'last_pull_$gameId';
+
+  Future<DateTime?> _getLastPullTime(String gameId) async {
+    final prefs = await SharedPreferences.getInstance();
+    final stored = prefs.getString(_pullKey(gameId));
+    if (stored == null) return null;
+    return DateTime.tryParse(stored);
+  }
+
+  Future<void> _setLastPullTime(String gameId) async {
+    final prefs = await SharedPreferences.getInstance();
+    await prefs.setString(
+      _pullKey(gameId),
+      DateTime.now().toIso8601String(),
+    );
+  }
+
   /// Uploads all local save files for [game] to RomM.
   ///
   /// If [sessionStart] is provided, only files modified after that time are uploaded.
   /// Returns true if at least one file was uploaded successfully.
-  Future<bool> pushSaves(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'}) async {
+  Future<bool> pushSaves(Game game, String romPath,
+      {DateTime? sessionStart, String syncMode = 'both'}) async {
     try {
       final strategy = getStrategyForSlug(game.platformSlug);
-      if (strategy == null) {
-        return false;
-      }
+      if (strategy == null) return false;
 
-      final files = await strategy.getSaveFiles(game, romPath, sessionStart: sessionStart, syncMode: syncMode);
-      if (files.isEmpty) {
-        return false;
-      }
+      final files = await strategy.getSaveFiles(
+        game, romPath,
+        sessionStart: sessionStart,
+        syncMode: syncMode,
+      );
+      if (files.isEmpty) return false;
 
       int uploaded = 0;
       for (final file in files) {
         File uploadFile = file;
         bool isTempZip = false;
 
-        // If it's a directory, zip it first
         if (await FileSystemEntity.isDirectory(file.path)) {
           final zipPath = '${file.path}.zip';
           final encoder = ZipFileEncoder();
@@ -177,14 +217,30 @@ class SaveSyncService {
           isTempZip = true;
         }
 
-  final ok = await _rommService.uploadSave(game.id, uploadFile);
-  if (ok) uploaded++;
+        final filename = uploadFile.path
+            .split(RegExp(r'[/\\]'))
+            .last;
 
-  // Clean up temp zip
-  if (isTempZip && await uploadFile.exists()) {
-    await uploadFile.delete();
-  }
-}
+        final localHash = await _hashFile(uploadFile);
+        final storedHash = await _getStoredHash(game.id, filename);
+
+        if (storedHash != null && localHash == storedHash) {
+          if (isTempZip && await uploadFile.exists()) {
+            await uploadFile.delete();
+          }
+          continue;
+        }
+
+        final ok = await _rommService.uploadSave(game.id, uploadFile);
+        if (ok) {
+          uploaded++;
+          await _storeHash(game.id, filename, localHash);
+        }
+
+        if (isTempZip && await uploadFile.exists()) {
+          await uploadFile.delete();
+        }
+      }
 
       return uploaded > 0;
     } catch (e) {
@@ -198,35 +254,42 @@ class SaveSyncService {
   Future<bool> pullSave(Game game, String romPath) async {
     try {
       final strategy = getStrategyForSlug(game.platformSlug);
-      if (strategy == null) {
-        return false;
-      }
+      if (strategy == null) return false;
 
       final save = await _rommService.getLatestSave(game.id);
-      if (save == null) {
-        return false;
-      }
-      // print('[Pull] getLatestSave result: $save');
+      if (save == null) return false;
 
-      final downloadUrl = save['download_path'] as String? ?? save['url'] as String?;
-      if (downloadUrl == null) {
-        return false;
-      }
+      // Check remote updated_at vs last pull time
+      final remoteUpdatedAt = DateTime.tryParse(
+          save['updated_at']?.toString() ?? '');
+      final lastPull = await _getLastPullTime(game.id);
 
-      final bytes = await _rommService.downloadSave(downloadUrl);
-      if (bytes == null) {
+      if (lastPull != null &&
+          remoteUpdatedAt != null &&
+          !remoteUpdatedAt.isAfter(lastPull)) {
+        // Remote hasn't changed since last pull, skip
         return false;
       }
-      // print('[Pull] downloaded bytes: ${bytes?.length}');
 
-      final filename = save['file_name'] as String? ??
-          downloadUrl.split('/').last;
+      final downloadUrl = save['download_path'] as String?
+          ?? save['url'] as String?;
+      if (downloadUrl == null) return false;
 
-      final ok = await strategy.restoreSave(game, romPath, bytes, filename);
-      // print('[Pull] restoreSave result: $ok');
+      final bytes = await _rommService.downloadSave(
+          downloadUrl);
+      if (bytes == null) return false;
+
+      final filename = save['file_name'] as String?
+          ?? downloadUrl.split('/').last;
+
+      final ok = await strategy.restoreSave(
+          game, romPath, bytes, filename);
+
+      if (ok) {
+        await _setLastPullTime(game.id);
+      }
       return ok;
     } catch (e) {
-      // print('[Pull] error: $e'); // Removed print statement
       rethrow;
     }
   }
diff --git a/lib/providers/paginated_games_provider.dart b/lib/providers/paginated_games_provider.dart
new file mode 100644
index 0000000..af98da6
--- /dev/null
+++ b/lib/providers/paginated_games_provider.dart
@@ -0,0 +1,174 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../core/romm/romm_models.dart';
+import 'romm_provider.dart';
+
+class PaginatedGamesState {
+  final List<Game> games;
+  final bool isLoading;
+  final bool isLoadingMore;
+  final bool hasMore;
+  final int total;
+  final String? error;
+
+  const PaginatedGamesState({
+    this.games = const [],
+    this.isLoading = false,
+    this.isLoadingMore = false,
+    this.hasMore = true,
+    this.total = 0,
+    this.error,
+  });
+
+  PaginatedGamesState copyWith({
+    List<Game>? games,
+    bool? isLoading,
+    bool? isLoadingMore,
+    bool? hasMore,
+    int? total,
+    String? error,
+  }) =>
+      PaginatedGamesState(
+        games: games ?? this.games,
+        isLoading: isLoading ?? this.isLoading,
+        isLoadingMore: isLoadingMore ?? this.isLoadingMore,
+        hasMore: hasMore ?? this.hasMore,
+        total: total ?? this.total,
+        error: error,
+      );
+}
+
+class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> {
+  final Ref _ref;
+  static const int _pageSize = 50;
+
+  // Per-key cache: key = "$platformId|$search"
+  final Map<String, List<Game>> _cache = {};
+  final Map<String, int> _offsets = {};
+  final Map<String, int> _totals = {};
+
+  String? _currentPlatformId;
+  String? _currentSearch;
+
+  PaginatedGamesNotifier(this._ref) : super(const PaginatedGamesState());
+
+  String _key(String? platformId, String? search) =>
+      '${platformId ?? "all"}|${search ?? ""}';
+
+  Future<void> loadInitial({String? platformId, String? search}) async {
+    _currentPlatformId = platformId;
+    _currentSearch = search;
+    final key = _key(platformId, search);
+
+    // Serve from cache immediately if available
+    if (_cache.containsKey(key)) {
+      state = PaginatedGamesState(
+        games: _cache[key]!,
+        total: _totals[key] ?? _cache[key]!.length,
+        hasMore: (_offsets[key] ?? 0) < (_totals[key] ?? 0),
+        isLoading: false,
+      );
+      // Background refresh of first page only
+      _backgroundRefresh(platformId: platformId, search: search, key: key);
+      return;
+    }
+
+    // No cache — show loading and fetch
+    state = const PaginatedGamesState(isLoading: true);
+    final service = _ref.read(rommServiceProvider);
+    if (service == null) {
+      state = state.copyWith(isLoading: false, error: 'Not connected');
+      return;
+    }
+    try {
+      final result = await service.getGamesPage(
+        offset: 0,
+        limit: _pageSize,
+        platformId: platformId,
+        search: search,
+      );
+      _cache[key] = result.games;
+      _offsets[key] = result.games.length;
+      _totals[key] = result.total;
+      state = PaginatedGamesState(
+        games: result.games,
+        total: result.total,
+        hasMore: result.games.length < result.total,
+        isLoading: false,
+      );
+    } catch (e) {
+      state = state.copyWith(isLoading: false, error: e.toString());
+    }
+  }
+
+  Future<void> _backgroundRefresh({
+    required String? platformId,
+    required String? search,
+    required String key,
+  }) async {
+    final service = _ref.read(rommServiceProvider);
+    if (service == null) return;
+    try {
+      final result = await service.getGamesPage(
+        offset: 0,
+        limit: _pageSize,
+        platformId: platformId,
+        search: search,
+      );
+      // Only update if still on same key
+      if (_key(_currentPlatformId, _currentSearch) == key) {
+        _cache[key] = result.games;
+        _offsets[key] = result.games.length;
+        _totals[key] = result.total;
+        state = PaginatedGamesState(
+          games: result.games,
+          total: result.total,
+          hasMore: result.games.length < result.total,
+          isLoading: false,
+        );
+      }
+    } catch (_) {}
+  }
+
+  Future<void> loadMore() async {
+    if (state.isLoadingMore || !state.hasMore) return;
+    final service = _ref.read(rommServiceProvider);
+    if (service == null) return;
+    final key = _key(_currentPlatformId, _currentSearch);
+    final int offset = _offsets[key] ?? state.games.length;
+    state = state.copyWith(isLoadingMore: true);
+    try {
+      final result = await service.getGamesPage(
+        offset: offset,
+        limit: _pageSize,
+        platformId: _currentPlatformId,
+        search: _currentSearch,
+      );
+      final merged = [...state.games, ...result.games];
+      _cache[key] = merged;
+      _offsets[key] = merged.length;
+      _totals[key] = result.total;
+      state = PaginatedGamesState(
+        games: merged,
+        total: result.total,
+        hasMore: merged.length < result.total,
+        isLoadingMore: false,
+      );
+    } catch (e) {
+      state = state.copyWith(isLoadingMore: false, error: e.toString());
+    }
+  }
+
+  void reset() {
+    _cache.clear();
+    _offsets.clear();
+    _totals.clear();
+    _currentPlatformId = null;
+    _currentSearch = null;
+    state = const PaginatedGamesState();
+  }
+}
+
+final paginatedGamesProvider =
+    StateNotifierProvider<PaginatedGamesNotifier, PaginatedGamesState>((ref) {
+  return PaginatedGamesNotifier(ref);
+});
diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart
index 7ec6261..45fd81b 100644
--- a/lib/ui/screens/library_screen.dart
+++ b/lib/ui/screens/library_screen.dart
@@ -3,11 +3,12 @@ import 'dart:convert';
 import 'dart:io';
 import 'package:dio/dio.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:shared_preferences/shared_preferences.dart';
 import '../../providers/library_provider.dart';
 import '../../providers/download_provider.dart';
 import '../../providers/romm_provider.dart';
+import '../../providers/paginated_games_provider.dart';
 import '../../core/storage/directory_service.dart';
 import '../../core/romm/romm_models.dart';
 import '../../core/emulator/strategies/windows_strategy.dart';
@@ -25,36 +26,36 @@ class LibraryScreen extends ConsumerStatefulWidget {
 }
 
 class _LibraryScreenState extends ConsumerState<LibraryScreen> {
-  Map<String, bool> _downloadedStates = {};
-  bool _downloadStatesLoaded = false;
+  final Map<String, bool> _downloadedStates = {};
   late TextEditingController _searchController;
+  final FocusNode _focusNode = FocusNode();
+  final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey =
+      GlobalKey<RefreshIndicatorState>();
+  late ScrollController _scrollController;
 
   @override
   void initState() {
     super.initState();
     _searchController = TextEditingController(text: ref.read(searchQueryProvider));
+    _scrollController = ScrollController();
+    _scrollController.addListener(_onScroll);
+    WidgetsBinding.instance.addPostFrameCallback((_) {
+      _focusNode.requestFocus();
+    });
   }
 
   @override
   void dispose() {
     _searchController.dispose();
+    _focusNode.dispose();
+    _scrollController.dispose();
     super.dispose();
   }
 
-  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);
-        return MapEntry(game.id, isDownloaded);
-      }),
-    );
-    if (mounted) {
-      setState(() {
-        _downloadedStates = Map.fromEntries(results);
-        _downloadStatesLoaded = true;
-      });
+  void _onScroll() {
+    if (!_scrollController.hasClients) return;
+    if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 600) {
+      ref.read(paginatedGamesProvider.notifier).loadMore();
     }
   }
 
@@ -68,6 +69,15 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
     }
   }
 
+  Future<void> _refreshLibrary() async {
+    ref.invalidate(platformsProvider);
+    ref.read(paginatedGamesProvider.notifier).reset();
+    await ref.read(paginatedGamesProvider.notifier).loadInitial(
+      platformId: ref.read(selectedPlatformIdProvider)?.toString(),
+      search: ref.read(searchQueryProvider).isEmpty ? null : ref.read(searchQueryProvider),
+    );
+  }
+
   void _startDownload(BuildContext context, WidgetRef ref, Game game) {
     final service = ref.read(rommServiceProvider);
     if (service == null) {
@@ -388,9 +398,7 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
   Widget build(BuildContext context) {
     final platformsAsync = ref.watch(platformsProvider);
     final selectedPlatformId = ref.watch(selectedPlatformIdProvider);
-    final searchQuery = ref.watch(searchQueryProvider);
-    final gamesAsync = ref.watch(allGamesProvider);
-    final filteredGames = ref.watch(filteredGamesProvider);
+    final paginatedState = ref.watch(paginatedGamesProvider);
     final cardAspectRatio = ref.watch(cardAspectRatioProvider);
     final columnCount = ref.watch(columnCountProvider);
     final cardSpacing = ref.watch(cardSpacingProvider);
@@ -399,12 +407,27 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
     final rommConfigAsync = ref.watch(rommConfigProvider);
     final directoryServiceAsync = ref.watch(directoryServiceProvider);
 
+    // Trigger initial load once service becomes available
+    ref.listen(rommServiceProvider, (prev, next) {
+      if (prev == null && next != null) {
+        ref.read(paginatedGamesProvider.notifier).loadInitial(platformId: null);
+      }
+    });
+
+    // Reload when platform changes
+    ref.listen<int?>(selectedPlatformIdProvider, (prev, next) {
+      if (prev != next) {
+        ref.read(paginatedGamesProvider.notifier).loadInitial(
+          platformId: next?.toString(),
+        );
+      }
+    });
+
     final appBarTitle = rommConfigAsync.when(
       data: (config) {
         final uri = Uri.tryParse(config.baseUrl);
         final host = uri?.host ?? config.baseUrl;
-        final totalGames = gamesAsync.asData?.value.length;
-        final gameCountStr = totalGames != null ? ' • $totalGames games' : '';
+        final gameCountStr = ' • ${paginatedState.total} games';
         return 'Freegosy • $host$gameCountStr';
       },
       loading: () => 'Freegosy',
@@ -413,159 +436,130 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
 
     return Scaffold(
       appBar: AppBar(title: Text(appBarTitle)),
-      body: ExcludeSemantics(
-        child: Column(
-          children: [
-            Padding(
-              padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
-              child: TextField(
-                controller: _searchController,
-                decoration: InputDecoration(
-                  hintText: 'Search games...',
-                  prefixIcon: const Icon(Icons.search),
-                  border: OutlineInputBorder(
-                    borderRadius: BorderRadius.circular(8.0),
+      body: KeyboardListener(
+        focusNode: _focusNode,
+        autofocus: true,
+        onKeyEvent: (KeyEvent event) {
+          if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.f5) {
+            _refreshIndicatorKey.currentState?.show();
+          }
+        },
+        child: ExcludeSemantics(
+          child: Column(
+            children: [
+              Padding(
+                padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
+                child: TextField(
+                  controller: _searchController,
+                  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;
+                    ref.read(paginatedGamesProvider.notifier).loadInitial(
+                      platformId: ref.read(selectedPlatformIdProvider)?.toString(),
+                      search: value.isEmpty ? null : 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;
-                },
-              ),
-              loading: () => const LinearProgressIndicator(),
-              error: (e, s) => Text('Error loading platforms: $e'),
-            ),
-            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),
-                    ),
-                  ),
+              platformsAsync.when(
+                data: (platforms) => PlatformFilterBar(
+                  platforms: platforms,
+                  selectedPlatformId: selectedPlatformId,
+                  onSelected: (platform) {
+                    ref.read(selectedPlatformIdProvider.notifier).state = platform?.id;
+                  },
                 ),
-                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),
+                loading: () => const LinearProgressIndicator(),
+                error: (e, s) => Text('Error loading platforms: $e'),
+              ),
+              Expanded(
+                child: paginatedState.isLoading
+                  ? buildSkeletonGrid(cardAspectRatio, columnCount, cardSpacing, context)
+                  : paginatedState.error != null
+                    ? Center(child: Padding(
+                        padding: const EdgeInsets.all(16),
+                        child: Text('Error: ${paginatedState.error}', style: const TextStyle(color: Colors.red)),
+                      ))
+                    : Column(
+                        children: [
+                          Padding(
+                            padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 2.0),
+                            child: Align(
+                              alignment: Alignment.centerRight,
+                              child: Text(
+                                selectedPlatformId == null
+                                  ? 'Showing ${paginatedState.games.length} of ${paginatedState.total} games'
+                                  : 'Showing ${paginatedState.games.length} of ${paginatedState.total} games',
+                                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');
-                            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(
+                              key: _refreshIndicatorKey,
+                              onRefresh: _refreshLibrary,
+                              child: paginatedState.games.isEmpty
+                                ? const CustomScrollView(slivers: [
+                                    SliverFillRemaining(child: Center(child: Text('No games found'))),
+                                  ])
+                                : GridView.builder(
+                                    controller: _scrollController,
+                                    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),
-                                  cacheExtent: 800,
-                                  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() ?? '');
-                                    final coverUrl = ref.read(rommServiceProvider)?.resolveCoverUrl(game);
-
-                                    if (dirService == null) {
+                                    itemCount: paginatedState.games.length + (paginatedState.isLoadingMore ? 1 : 0),
+                                    itemBuilder: (context, index) {
+                                      if (index == paginatedState.games.length) {
+                                        return const Center(child: Padding(
+                                          padding: EdgeInsets.all(16),
+                                          child: CircularProgressIndicator(),
+                                        ));
+                                      }
+                                      final game = paginatedState.games[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)
-                                            : null,
+                                        onLongPress: isWindowsGame ? () => _handleWindowsConfig(context, ref, game) : null,
                                         child: GameCard(
-                                          game: game,
-                                          coverUrl: coverUrl,
-                                          showTitle: showTitle,
-                                          showButtonsOnHover: showButtonsOnHover,
+                                          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),
                                         ),
                                       );
-                                    }
-
-                                    return GestureDetector(
-                                      onLongPress: isWindowsGame
-                                          ? () => _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),
-                                      ),
-                                    );
-                                  },
-                                ),
-                        ),
+                                    },
+                                  ),
+                            ),
+                          ),
+                        ],
                       ),
-                    ],
-                  );
-                },
               ),
-            ),
-          ],
+            ],
+          ),
         ),
       ),
     );
diff --git a/pubspec.lock b/pubspec.lock
index 16720af..69cef31 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -90,7 +90,7 @@ packages:
     source: hosted
     version: "0.3.3+8"
   crypto:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: crypto
       sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
diff --git a/pubspec.yaml b/pubspec.yaml
index 9a1ebc5..481f0f0 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
+  crypto: ^3.0.3
   cached_network_image: ^3.3.1
 
   # The following adds the Cupertino Icons font to your application.

Clone this wiki locally