// Resume-on-launch (#54). The #52 teardown tears the audio_service // session down (and clears mediaItem) when idle/dismissed, so the // headset / lock-screen play button otherwise has nothing to resume. // This controller persists the live queue + index + position + #415 // source to a single-row drift snapshot on track change / pause / app // teardown, and on construction restores the last snapshot into the // handler PAUSED (the user sees their last track and continues with // play / a media button — we never auto-blast on launch). // // Persist guard: when the handler clears (teardown → mediaItem null / // empty queue) we deliberately do NOT write, so the last good snapshot // survives for the next launch — that survival is the whole point. import 'dart:async'; import 'dart:convert'; import 'package:drift/drift.dart' as drift; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../auth/auth_provider.dart'; import '../models/track.dart'; import '../player/player_provider.dart'; import 'audio_cache_manager.dart' show appDbProvider; import 'db.dart'; class ResumeController with WidgetsBindingObserver { ResumeController(this._ref); final Ref _ref; final _subs = >[]; Timer? _debounce; bool _disposed = false; Future start() async { try { // audioHandlerProvider throws until main() overrides it (the real // app always does). In tests / no-audio environments there's // nothing to resume — stay inert. _ref.read(audioHandlerProvider); } catch (_) { return; } if (_disposed) return; await _restore(); if (_disposed) return; final h = _ref.read(audioHandlerProvider); // Media-button-when-torn-down hook (#448): play() invokes this when // mediaItem is null so a headset/watch press resumes the snapshot. h.setResumeHook(resumeFromMediaButton); _subs.add(h.mediaItem.listen((_) => _schedulePersist())); _subs.add(h.playbackState.listen((_) => _schedulePersist())); WidgetsBinding.instance.addObserver(this); } void _schedulePersist() { if (_disposed) return; _debounce?.cancel(); _debounce = Timer(const Duration(seconds: 3), () { unawaited(_persist()); }); } Future _persist() async { if (_disposed) return; try { final h = _ref.read(audioHandlerProvider); final tracks = h.queuedTracks; final mi = h.mediaItem.value; // Cleared by teardown — keep the last good snapshot for next // launch rather than wiping it with an empty queue. if (tracks.isEmpty || mi == null) return; var idx = tracks.indexWhere((t) => t.id == mi.id); if (idx < 0) idx = 0; final blob = jsonEncode({ 'v': 1, 'source': h.queueSource, 'index': idx, 'position_ms': h.position.inMilliseconds, 'tracks': tracks.map((t) => t.toJson()).toList(), }); final db = _ref.read(appDbProvider); await db.into(db.cachedResumeState).insertOnConflictUpdate( CachedResumeStateCompanion.insert( json: blob, updatedAt: drift.Value(DateTime.now()), ), ); } catch (e, st) { debugPrint('resume_controller: persist failed: $e\n$st'); } } /// Launch path: restore the last session PAUSED (no auto-blast). Future _restore() async { try { await _loadAndRestore(); } catch (e, st) { debugPrint('resume_controller: restore failed: $e\n$st'); } } /// Media-button path (#448): the user pressed play on the headset / /// watch / lock screen while the session was fully torn down (#52) and /// nothing is loaded. Restore the snapshot, then START playback (they /// asked to play). Registered as the handler's resume hook in start(). /// No-op if there's nothing to resume. Future resumeFromMediaButton() async { try { if (await _loadAndRestore()) { await _ref.read(audioHandlerProvider).play(); } } catch (e, st) { debugPrint('resume_controller: media-button resume failed: $e\n$st'); } } /// Shared loader. Restores the last persisted session PAUSED and /// returns whether it actually restored a queue. Guards: an already- /// active session (don't stomp), missing auth, no/empty snapshot. Future _loadAndRestore() async { final h = _ref.read(audioHandlerProvider); if (h.mediaItem.value != null) return false; final url = await _ref.read(serverUrlProvider.future); final token = await _ref.read(secureStorageProvider).read(key: 'session_token'); if (url == null || url.isEmpty || token == null || token.isEmpty) { return false; } final db = _ref.read(appDbProvider); final row = await db.select(db.cachedResumeState).getSingleOrNull(); if (row == null) return false; final m = jsonDecode(row.json) as Map; final rawTracks = (m['tracks'] as List?) ?? const []; final tracks = rawTracks .map((e) => TrackRef.fromJson(e as Map)) .toList(); if (tracks.isEmpty) return false; final idx = (m['index'] as num?)?.toInt() ?? 0; final posMs = (m['position_ms'] as num?)?.toInt() ?? 0; final source = m['source'] as String?; await _ref.read(playerActionsProvider).restoreQueue( tracks, initialIndex: idx, position: Duration(milliseconds: posMs), source: source, ); return true; } @override void didChangeAppLifecycleState(AppLifecycleState state) { // App backgrounded / killed: persist durably right now (the debounce // may not fire before teardown). if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) { _debounce?.cancel(); unawaited(_persist()); } } void dispose() { _disposed = true; _debounce?.cancel(); WidgetsBinding.instance.removeObserver(this); for (final s in _subs) { s.cancel(); } _subs.clear(); } } /// Read once at app start (app.dart postFrame). On construction it /// restores the last persisted session (paused) then persists /// queue/index/position on track change, pause, and app teardown. /// Disposed via ref.onDispose when the scope tears down. final resumeControllerProvider = Provider((ref) { final c = ResumeController(ref); ref.onDispose(c.dispose); // ignore: unawaited_futures c.start(); return c; });