feat(offline): #427 S1 — reachability offline marker

offlineProvider: a Notifier<bool> driven by a periodic /healthz
probe. Offline after N=3 consecutive failed probes; recovers on
the first success. Slow heartbeat when online (30s), faster when
offline (10s) so recovery is noticed quickly. 5s initial delay for
provider warmup; optimistic (false) until proven otherwise.

Deliberately NOT coupled to connectivityProvider — subscribing to
that StreamProvider eagerly mounts its 2s timeout and leaks a
pending Timer through widget tests (the MutationReplayer bug).
/healthz failing already covers interface-down. No .timeout()
wrapper either (dio's own timeouts bound the probe) so the only
Timers are the tracked initial+periodic, both cancelled via
ref.onDispose — the proven smoke-safe shape.

Wired in app.dart postFrame to start the poller. No UI yet; S4
gates system-playlist play + Shuffle-all on it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 16:13:55 -04:00
parent c7ee0871a5
commit a0aea00667
2 changed files with 110 additions and 0 deletions
+5
View File
@@ -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<MinstrelApp> {
// 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);
});
}
+105
View File
@@ -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<bool> {
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<void> _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<void> 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, bool>(OfflineMonitor.new);