import 'dart:async'; import 'dart:typed_data'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/cache/frame_cache.dart'; import '../../core/config/env.dart'; import '../../core/region/region_config.dart'; import '../../core/region/region_repository.dart'; import '../../data/radar/arpa_radar_source.dart'; import '../../data/radar/dpc_radar_source.dart'; import '../../data/radar/mock_radar_source.dart'; import '../../data/radar/radar_manifest.dart'; import '../../data/radar/radar_source.dart'; /// What the radar timeline is showing right now. class RadarTimelineState { const RadarTimelineState({ this.manifest, this.index = 0, this.isPlaying = false, this.isLoading = true, this.error, }); /// The last manifest that loaded successfully. /// /// Kept across a failed refresh on purpose: showing yesterday's frames with /// their age stated beats showing an empty map. final RadarManifest? manifest; /// Playhead position in [RadarManifest.frames]. final int index; final bool isPlaying; /// A load is in flight. Not the same as having nothing to show. final bool isLoading; /// Why the most recent load failed, or null if it succeeded. /// /// Non-null together with a non-null [manifest] is the normal degraded state: /// stale data on screen, banner explaining it. final RadarUnavailableException? error; bool get hasFrames => manifest?.frames.isNotEmpty ?? false; int get frameCount => manifest?.frames.length ?? 0; RadarFrame? get currentFrame { final frames = manifest?.frames; if (frames == null || frames.isEmpty) return null; return frames[index.clamp(0, frames.length - 1)]; } bool get isAtNewestFrame => frameCount == 0 || index >= frameCount - 1; RadarTimelineState copyWith({ RadarManifest? manifest, int? index, bool? isPlaying, bool? isLoading, RadarUnavailableException? error, bool clearError = false, }) => RadarTimelineState( manifest: manifest ?? this.manifest, index: index ?? this.index, isPlaying: isPlaying ?? this.isPlaying, isLoading: isLoading ?? this.isLoading, error: clearError ? null : (error ?? this.error), ); } /// Drives the radar timeline: loading, playback and prefetching. class RadarTimeline extends Notifier { /// How long each frame is held during playback. static const Duration framePeriod = Duration(milliseconds: 450); /// Extra dwell on the newest frame, so a loop ends on the present instead of /// snapping straight back to the oldest frame. static const Duration lastFrameDwell = Duration(milliseconds: 1100); /// How many frames either side of the playhead to keep loaded. /// /// Wide enough that playback never waits on a fetch, narrow enough that a /// long timeline does not sit entirely in memory. static const int prefetchRadius = 4; Timer? _playbackTimer; final FrameCache _cache = FrameCache(); RadarSource? _source; /// Guards against a prefetch that finishes after the playhead has moved on, /// or after the provider has been disposed. int _generation = 0; bool _disposed = false; @override RadarTimelineState build() { ref.onDispose(() { _disposed = true; _playbackTimer?.cancel(); _cache.clear(); }); // Deferred to a microtask rather than started here: refresh() writes to // `state`, and while build() is still running the provider has no state to // write to yet — Riverpod throws "tried to read the state of an // uninitialized provider". The microtask runs as soon as build() returns. Future.microtask(refresh).ignore(); return const RadarTimelineState(); } /// Visible for tests: the bytes currently held for the timeline. FrameCache get cache => _cache; /// Loads the newest manifest and positions the playhead on the newest frame. Future refresh() async { state = state.copyWith(isLoading: true); try { final source = await _resolveSource(); final manifest = await source.getLatestManifest(); if (_disposed) return; _generation++; // Land on the newest frame: someone opening the app wants to know what // the sky is doing now, not two hours ago. final newestIndex = manifest.frames.isEmpty ? 0 : manifest.frames.length - 1; state = RadarTimelineState( manifest: manifest, index: newestIndex, isPlaying: state.isPlaying, isLoading: false, ); await _prefetchAround(newestIndex); } on RadarUnavailableException catch (error) { if (_disposed) return; // Keep whatever manifest we already had. The banner reports the age. state = state.copyWith(isLoading: false, error: error); } } void play() { if (!state.hasFrames || state.isPlaying) return; state = state.copyWith(isPlaying: true); _scheduleNextFrame(); } void pause() { _playbackTimer?.cancel(); _playbackTimer = null; if (state.isPlaying) { state = state.copyWith(isPlaying: false); } } void togglePlayPause() => state.isPlaying ? pause() : play(); /// Moves the playhead, pausing playback: a deliberate scrub should not fight /// the timer for control of the frame. void seek(int index) { if (!state.hasFrames) return; pause(); _setIndex(index); } void step(int delta) { if (!state.hasFrames) return; pause(); _setIndex(state.index + delta); } /// Called when the app leaves the foreground. /// /// Playback keeps a timer running and decodes an image several times a /// second; continuing that behind another app would drain the battery for /// nothing, since there is no one watching. void onAppBackgrounded() => pause(); void _setIndex(int index) { final count = state.frameCount; if (count == 0) return; final clamped = index.clamp(0, count - 1); if (clamped == state.index) return; _generation++; state = state.copyWith(index: clamped); unawaited(_prefetchAround(clamped)); } void _scheduleNextFrame() { _playbackTimer?.cancel(); if (!state.isPlaying || !state.hasFrames) return; final delay = state.isAtNewestFrame ? lastFrameDwell : framePeriod; _playbackTimer = Timer(delay, () { if (_disposed || !state.isPlaying) return; final count = state.frameCount; final next = state.index >= count - 1 ? 0 : state.index + 1; _generation++; state = state.copyWith(index: next); unawaited(_prefetchAround(next)); _scheduleNextFrame(); }); } /// Returns the bytes for [frame], from cache when possible. Future bytesFor(RadarFrame frame) async { final source = await _resolveSource(); return _cache.load(frame.path, () => source.loadFrameBytes(frame)); } /// Loads the window around [index], nearest first. /// /// Order matters: during playback the next frame is needed within /// milliseconds, while the far edge of the window can wait. Future _prefetchAround(int index) async { final manifest = state.manifest; if (manifest == null || manifest.frames.isEmpty) return; final generation = _generation; final frames = manifest.frames; final window = [index]; for (var offset = 1; offset <= prefetchRadius; offset++) { // Ahead first: playback moves forward, so that is the frame that will be // needed soonest. if (index + offset < frames.length) window.add(index + offset); if (index - offset >= 0) window.add(index - offset); } _cache.retainOnly(window.map((i) => frames[i].path).toSet()); for (final position in window) { if (_disposed || generation != _generation) return; final frame = frames[position]; if (_cache.contains(frame.path)) continue; try { await bytesFor(frame); } on RadarUnavailableException { // One missing frame is not a timeline failure: the worker may be // mid-publish. The overlay holds the previous image instead. continue; } } } Future _resolveSource() async { final existing = _source; if (existing != null) return existing; final resolved = await ref.read(radarSourceProvider.future); _source = resolved; return resolved; } } /// Chooses the radar adapter for [region]. /// /// A runtime override wins over the region default so a build can be pointed at /// live data without editing assets, but neither can select an adapter the /// region does not list — that would be a configuration typo silently changing /// where the data comes from. RadarSource createRadarSource(RegionConfig region) { var adapter = region.radar.defaultAdapter; if (Env.radarSource.isNotEmpty) { final requested = RadarAdapter.parse(Env.radarSource); if (!region.radar.availableAdapters.contains(requested)) { throw StateError( 'RADAR_SOURCE=${Env.radarSource} is not among the adapters ' '${region.id} declares: ' '${region.radar.availableAdapters.map((a) => a.name).join(', ')}', ); } adapter = requested; } return switch (adapter) { RadarAdapter.mock => MockRadarSource(), RadarAdapter.dpc => _createDpcSource(), RadarAdapter.arpa => const ArpaRadarSource(), }; } RadarSource _createDpcSource() { if (Env.radarManifestUrl.isEmpty) { // Falling back to mock here would put demo frames on screen under the // label of live data, which is exactly the confusion the adapter split // exists to prevent. Failing names the missing setting instead. throw StateError( 'the dpc radar adapter needs RADAR_MANIFEST_URL; set it in env.json or ' 'select RADAR_SOURCE=mock', ); } return DpcRadarSource(manifestUrl: Env.radarManifestUrl); } /// The active radar adapter. /// /// Separate from the timeline so tests can substitute a source without /// standing up assets or a network, and so the choice stays a single decision /// made in one place. final radarSourceProvider = FutureProvider((ref) async { final region = await ref.watch(regionConfigProvider.future); return createRadarSource(region); }); final radarTimelineProvider = NotifierProvider(RadarTimeline.new);