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>
127 lines
4.2 KiB
Dart
127 lines
4.2 KiB
Dart
// Subscribes to the server's Server-Sent Events stream
|
|
// (GET /api/events/stream, see Fable #392) and exposes a parsed event
|
|
// stream as a Riverpod StreamProvider. Consumers wire invalidation
|
|
// behavior in live_events_dispatcher.dart.
|
|
//
|
|
// On disconnect (network blip, server restart, token rotation), the
|
|
// provider's StreamController completes; the dispatcher's auto-rebuild
|
|
// when the auth state changes re-subscribes. Explicit exponential
|
|
// backoff lives at the dispatcher layer so we don't re-create the dio
|
|
// connection too aggressively.
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../auth/auth_provider.dart';
|
|
import '../library/library_providers.dart' show dioProvider;
|
|
|
|
/// Parsed event from the server's SSE stream. `kind` follows
|
|
/// "domain.action" naming; `data` carries the payload map.
|
|
class LiveEvent {
|
|
const LiveEvent({required this.kind, required this.userId, required this.data});
|
|
|
|
final String kind;
|
|
final String userId;
|
|
final Map<String, dynamic> data;
|
|
|
|
@override
|
|
String toString() => 'LiveEvent($kind, user=$userId, data=$data)';
|
|
}
|
|
|
|
/// Streams parsed LiveEvent values from /api/events/stream. Errors and
|
|
/// disconnects surface as stream errors; the dispatcher decides whether
|
|
/// to retry.
|
|
final liveEventsProvider = StreamProvider<LiveEvent>((ref) async* {
|
|
// Gate the subscription on having both a server URL and a session
|
|
// token. If either is missing, emit nothing and let the provider
|
|
// auto-rebuild when auth state lands.
|
|
final token = await ref.watch(sessionTokenProvider.future);
|
|
if (token == null || token.isEmpty) {
|
|
return;
|
|
}
|
|
final dio = await ref.watch(dioProvider.future);
|
|
|
|
final controller = StreamController<LiveEvent>();
|
|
ref.onDispose(controller.close);
|
|
|
|
// ignore: unawaited_futures
|
|
_runSubscription(dio, controller);
|
|
|
|
yield* controller.stream;
|
|
});
|
|
|
|
/// Runs the dio streaming request and pushes parsed events into
|
|
/// [controller]. Closes the controller when the stream ends or errors.
|
|
Future<void> _runSubscription(Dio dio, StreamController<LiveEvent> controller) async {
|
|
try {
|
|
final resp = await dio.get<ResponseBody>(
|
|
'/api/events/stream',
|
|
options: Options(
|
|
responseType: ResponseType.stream,
|
|
// No timeout — the server emits 15s heartbeats; idle timeouts
|
|
// on the client side would tear down a healthy connection.
|
|
receiveTimeout: Duration.zero,
|
|
headers: const {'Accept': 'text/event-stream'},
|
|
),
|
|
);
|
|
|
|
// SSE frames are delimited by blank lines. Accumulate raw bytes
|
|
// into a string buffer; flush parsed events on each "\n\n".
|
|
var buffer = '';
|
|
await for (final chunk in resp.data!.stream) {
|
|
buffer += utf8.decode(chunk, allowMalformed: true);
|
|
while (true) {
|
|
final i = buffer.indexOf('\n\n');
|
|
if (i < 0) break;
|
|
final frame = buffer.substring(0, i);
|
|
buffer = buffer.substring(i + 2);
|
|
final event = _parseFrame(frame);
|
|
if (event != null && !controller.isClosed) {
|
|
controller.add(event);
|
|
}
|
|
}
|
|
}
|
|
if (!controller.isClosed) {
|
|
await controller.close();
|
|
}
|
|
} catch (e, st) {
|
|
if (!controller.isClosed) {
|
|
controller.addError(e, st);
|
|
await controller.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parses one SSE frame. Heartbeat comments (starting with ":") and
|
|
/// frames without a `data:` line return null. Frames with a `data:`
|
|
/// payload that doesn't parse as JSON are also dropped (logged at
|
|
/// debug level by the caller if needed).
|
|
LiveEvent? _parseFrame(String frame) {
|
|
String? kind;
|
|
String? dataLine;
|
|
for (final line in frame.split('\n')) {
|
|
if (line.isEmpty || line.startsWith(':')) {
|
|
continue;
|
|
}
|
|
if (line.startsWith('event:')) {
|
|
kind = line.substring(6).trim();
|
|
} else if (line.startsWith('data:')) {
|
|
dataLine = line.substring(5).trim();
|
|
}
|
|
}
|
|
if (dataLine == null) return null;
|
|
try {
|
|
final decoded = jsonDecode(dataLine) as Map<String, dynamic>;
|
|
return LiveEvent(
|
|
kind: kind ?? (decoded['kind'] as String? ?? ''),
|
|
userId: decoded['user_id'] as String? ?? '',
|
|
data: (decoded['data'] as Map<String, dynamic>?) ?? const {},
|
|
);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|