diff --git a/flutter_client/lib/app.dart b/flutter_client/lib/app.dart index d4a1150e..93a6684c 100644 --- a/flutter_client/lib/app.dart +++ b/flutter_client/lib/app.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'cache/cache_filler.dart'; import 'cache/metadata_prefetcher.dart'; +import 'cache/offline_provider.dart'; import 'cache/mutation_queue.dart'; import 'cache/prefetcher.dart'; import 'cache/sync_controller.dart'; @@ -61,6 +62,10 @@ class _MinstrelAppState extends ConsumerState { // scoring, scrobbles, and system-playlist rotation, and is the // path that carries the `source` tag for #415. ref.read(playEventsReporterProvider); + // Offline marker (#427 S1): periodic /healthz reachability + // probe → offlineProvider. Read here to start the poller; S4 + // gates system-playlist play + Shuffle-all on it. + ref.read(offlineProvider); }); } diff --git a/flutter_client/lib/cache/offline_provider.dart b/flutter_client/lib/cache/offline_provider.dart new file mode 100644 index 00000000..0ef4bcc4 --- /dev/null +++ b/flutter_client/lib/cache/offline_provider.dart @@ -0,0 +1,105 @@ +// Reachability-based offline marker (#427 S1). +// +// `connectivityProvider` only knows whether a network *interface* is +// up — not whether the Minstrel server is actually reachable (captive +// portals, server down, DNS, VPN-only routes all read "online"). This +// is the single source of truth other features gate on: offline = +// the server failed its /healthz probe N times in a row; recovery on +// the first success. +// +// Deliberately NOT coupled to connectivityProvider: subscribing to +// that StreamProvider eagerly mounts its 2s checkConnectivity timeout +// and leaks a pending Timer through widget tests that never reach the +// auth state (the bug fixed for MutationReplayer). /healthz failing +// already covers interface-down — it just fails the probe. +// +// Optimistic: assume online until proven offline, so a cold launch +// behaves normally and only flips after sustained unreachability. + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/endpoints/health.dart'; +import '../library/library_providers.dart' show dioProvider; + +class OfflineMonitor extends Notifier { + Timer? _initial; + Timer? _periodic; + int _consecutiveFails = 0; + + /// Consecutive failed probes before we declare offline. Small + /// enough to react within ~half a minute, large enough that a + /// single dropped request doesn't flip the whole UI. + static const _threshold = 3; + + /// Probe cadence. Slower when believed-online (cheap heartbeat); + /// faster when offline so recovery is noticed quickly. + static const _onlineInterval = Duration(seconds: 30); + static const _offlineInterval = Duration(seconds: 10); + + /// Let auth + server-url + dio providers finish async warmup + /// before the first probe so a cold start doesn't false-positive. + static const _initialDelay = Duration(seconds: 5); + + @override + bool build() { + ref.onDispose(() { + _initial?.cancel(); + _periodic?.cancel(); + }); + _initial = Timer(_initialDelay, () { + _check(); + _arm(); + }); + return false; // optimistic until a probe says otherwise + } + + void _arm() { + _periodic?.cancel(); + _periodic = Timer.periodic( + state ? _offlineInterval : _onlineInterval, + (_) => _check(), + ); + } + + Future _check() async { + final Dio dio; + try { + // Not configured yet (no server URL) → don't flip; the app is + // still on the connect screen and "offline" is meaningless. + dio = await ref.read(dioProvider.future); + } catch (_) { + return; + } + try { + // dio's own connect/receive timeouts bound this — no extra + // Timer (which would leak in widget tests). + await HealthApi(dio).check(); + _consecutiveFails = 0; + _set(false); + } catch (_) { + _consecutiveFails++; + if (_consecutiveFails >= _threshold) { + _set(true); + } + } + } + + void _set(bool offline) { + if (state == offline) return; + state = offline; + _arm(); // cadence follows the new state + } + + /// Force an immediate probe — e.g. a user-initiated retry. Public + /// so S4's offline surfaces can offer a "try again" affordance. + Future recheck() => _check(); +} + +/// `true` when the server is unreachable. Watch this to gate +/// online-only affordances; read it once at app start to activate +/// the poller. +final offlineProvider = + NotifierProvider(OfflineMonitor.new);