import 'dart:async'; import 'dart:typed_data'; import 'datalog_db.dart'; enum PlaybackStatus { idle, playing, paused } /// Loads stored frames from SQLite and replays them through a stream /// at configurable speed. Consumers watch [frameStream] exactly like /// the live BT stream — the parser layer is unaware of the difference. class DatalogPlayer { final DatalogDb _db; List _frames = []; int _position = 0; // index into _frames double _speed = 1.0; // 1.0 = real-time, 2.0 = 2× faster PlaybackStatus _status = PlaybackStatus.idle; PlaybackStatus get status => _status; int get position => _position; int get totalFrames => _frames.length; double get speed => _speed; set speed(double v) => _speed = v.clamp(0.25, 8.0); final StreamController _controller = StreamController.broadcast(); Stream get frameStream => _controller.stream; /// Callback fired on each tick — lets UI update a progress indicator. void Function(int position, int total)? onProgress; Timer? _timer; DatalogPlayer(this._db); /// Load all frames for [sessionId] into memory. Future load(int sessionId) async { await stop(); _frames = await _db.getFrames(sessionId); _position = 0; _status = PlaybackStatus.idle; } /// Start or resume playback. void play() { if (_status == PlaybackStatus.playing) return; if (_frames.isEmpty) return; _status = PlaybackStatus.playing; _scheduleNext(); } void pause() { _timer?.cancel(); _timer = null; if (_status == PlaybackStatus.playing) { _status = PlaybackStatus.paused; } } Future stop() async { _timer?.cancel(); _timer = null; _position = 0; _status = PlaybackStatus.idle; } /// Jump to a specific frame index. void seek(int index) { _position = index.clamp(0, _frames.length - 1); onProgress?.call(_position, _frames.length); } void _scheduleNext() { if (_position >= _frames.length) { _status = PlaybackStatus.idle; onProgress?.call(_frames.length, _frames.length); return; } // Calculate delay to next frame using original timestamps Duration delay = const Duration(milliseconds: 100); // default 10 Hz if (_position + 1 < _frames.length) { final gap = _frames[_position + 1].timestamp .difference(_frames[_position].timestamp); delay = Duration( microseconds: (gap.inMicroseconds / _speed).round(), ); // Clamp to 10ms–2s to handle gaps from pauses/reconnects delay = delay.clamp( const Duration(milliseconds: 10), const Duration(seconds: 2), ); } _timer = Timer(delay, () { if (_status != PlaybackStatus.playing) return; if (_position < _frames.length) { _controller.add(_frames[_position].data); onProgress?.call(_position, _frames.length); _position++; } _scheduleNext(); }); } void dispose() { _timer?.cancel(); _controller.close(); } } extension on Duration { Duration clamp(Duration min, Duration max) { if (this < min) return min; if (this > max) return max; return this; } }