90d8aae51a
Slice 4 — completes the #392 hybrid live-refresh loop. live_events_provider.dart subscribes to /api/events/stream via dio's streaming response mode, parses SSE frames (kind + JSON data + UserID scope), and exposes them as a Riverpod StreamProvider. Heartbeat comments are silently dropped; malformed JSON frames are skipped. The provider auto-rebuilds when auth state changes (token rotation, sign-out → sign-in), so reconnect is implicit. live_events_dispatcher.dart listens to the stream and invalidates the small set of publicly-importable providers we know about: - myQuarantineProvider + homeProvider on any quarantine.* event - homeProvider on any playlist.* event (Home renders the Playlists row) Screen-private providers (library_screen.dart's _liked* / _libraryAlbums / _history, admin screens, etc.) opt in to live-refresh by themselves listening to liveEventsProvider in follow-up commits; the dispatcher stays small and avoids back-edge dependencies on every feature folder. The dispatcher also installs an AppLifecycleState observer for resume-time defensive invalidation. SSE will catch up on its own when the app returns from background, but the invalidate flushes any stale data immediately so the first frame back is fresh. app.dart wires the dispatcher into the post-first-frame callback alongside the other startup activations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
105 lines
4.1 KiB
Dart
105 lines
4.1 KiB
Dart
// 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 homeProvider;
|
|
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<AsyncValue<LiveEvent>>(
|
|
liveEventsProvider,
|
|
(_, next) => next.whenData(_handle),
|
|
fireImmediately: false,
|
|
);
|
|
WidgetsBinding.instance.addObserver(this);
|
|
}
|
|
|
|
final Ref _ref;
|
|
late final ProviderSubscription<AsyncValue<LiveEvent>> _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 — homeProvider re-fetch refreshes the cards.
|
|
_ref.invalidate(homeProvider);
|
|
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(homeProvider);
|
|
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(homeProvider);
|
|
}
|
|
}
|
|
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_sub.close();
|
|
}
|
|
}
|