// Maps incoming LiveEvent kinds to provider invalidations. // // Activated by ref.read(liveEventsDispatcherProvider) in app.dart; once // activated, the dispatcher listens to liveEventsProvider for the // lifetime of the ProviderScope and invalidates the small set of // public-scoped providers we know about. // // Screen-scoped providers (file-private providers in library_screen.dart, // admin handlers, etc.) opt in to live-refresh by themselves listening // to liveEventsProvider — the dispatcher only handles cross-screen, // publicly-importable providers. This keeps the dispatcher small and // avoids a back-edge dependency from /shared onto every feature folder. // // Includes an AppLifecycleState resume handler that defensively // invalidates the same set on app foreground. SSE will catch up on its // own, but on cold-start or after a long background period the // re-invalidate is fast and avoids stale data flashing before the // stream reconnects. import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../library/library_providers.dart' show homeIndexProvider; import '../quarantine/quarantine_provider.dart'; import 'live_events_provider.dart'; /// Activates the SSE → invalidation dispatcher for the lifetime of the /// containing ProviderScope. Read this once at startup (from app.dart); /// the provider's body runs once, sets up listeners, and returns a /// sentinel value. final liveEventsDispatcherProvider = Provider<_LiveEventsDispatcher>((ref) { final d = _LiveEventsDispatcher(ref); ref.onDispose(d.dispose); return d; }); class _LiveEventsDispatcher with WidgetsBindingObserver { _LiveEventsDispatcher(this._ref) { // Subscribe to the event stream. Each emitted event invokes // _handle. Errors / disconnects auto-rebuild the underlying // provider; we don't need to react to them here. _sub = _ref.listen>( liveEventsProvider, (_, next) => next.whenData(_handle), fireImmediately: false, ); WidgetsBinding.instance.addObserver(this); } final Ref _ref; late final ProviderSubscription> _sub; void _handle(LiveEvent e) { // Map event kinds to provider invalidations. Keep this list short: // only providers reachable from /shared. Screen-private providers // listen to liveEventsProvider themselves. switch (e.kind) { case 'quarantine.flagged': case 'quarantine.unflagged': case 'quarantine.resolved': case 'quarantine.file_deleted': case 'quarantine.deleted_via_lidarr': _ref.invalidate(myQuarantineProvider); // Hidden / liked / album rows referencing a deleted track may // now be stale — homeIndexProvider re-fetch refreshes the cards. _ref.invalidate(homeIndexProvider); case 'playlist.created': case 'playlist.updated': case 'playlist.deleted': case 'playlist.tracks_changed': // Home renders a Playlists row; refresh it so a delete or add // is reflected immediately. Detail screens that need the // mutated row will get a separate invalidate from their own // listener (filed as a follow-up). _ref.invalidate(homeIndexProvider); case 'scan.run_started': case 'scan.run_finished': // Admin scan card provider lives in the admin feature folder // and is file-private today; will be invalidated by the admin // dashboard's own listener once that exists. break; default: // track.liked / track.unliked / request.status_changed reach // screen-private providers; their screens listen directly. break; } } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { // Defensive cold-start invalidation. SSE will catch up on its // own, but the re-invalidate flushes any stale data that // landed while the app was backgrounded. _ref.invalidate(myQuarantineProvider); _ref.invalidate(homeIndexProvider); } } void dispose() { WidgetsBinding.instance.removeObserver(this); _sub.close(); } }