Files
minstrel/flutter_client/lib/cache/resume_controller.dart
T
bvandeusen 41dd2892e3 feat(player): resume last session on launch (#54)
The #52 teardown clears the session when idle/dismissed, so the
headset / lock-screen play button had nothing to resume and the user
lost their place.

- track.dart: toJson() (round-trips fromJson)
- db.dart: CachedResumeState single-row snapshot, schema 9->10 + migration
- audio_handler.dart: queuedTracks getter
- player_provider.dart: restoreQueue() — configure + setQueueFromTracks
  + seek, no play (restores PAUSED)
- resume_controller.dart: restores last snapshot paused on launch;
  persists {source,index,position_ms,tracks} debounced on track
  change / pause and immediately on app teardown; never clobbers the
  saved snapshot when the queue is empty so a teardown stays
  recoverable; restore gated on auth + no active session
- app.dart: wired into postFrame

db.g.dart regenerated by CI per project convention. Deferred fast-follow:
media-button-when-fully-stopped re-init (needs a configure()-injected
callback; tracked on #54).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:57:58 -04:00

160 lines
5.5 KiB
Dart

// 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 = <StreamSubscription<dynamic>>[];
Timer? _debounce;
bool _disposed = false;
Future<void> 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);
_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<void> _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');
}
}
Future<void> _restore() async {
try {
final h = _ref.read(audioHandlerProvider);
// A session already started before restore ran (deep link, etc.)
// — don't stomp it.
if (h.mediaItem.value != null) return;
final url = await _ref.read(serverUrlProvider.future);
final token =
await _ref.read(secureStorageProvider).read(key: 'session_token');
// Streaming needs auth + base URL; nothing to resume without them.
if (url == null || url.isEmpty || token == null || token.isEmpty) {
return;
}
final db = _ref.read(appDbProvider);
final row = await db.select(db.cachedResumeState).getSingleOrNull();
if (row == null) return;
final m = jsonDecode(row.json) as Map<String, dynamic>;
final rawTracks = (m['tracks'] as List?) ?? const [];
final tracks = rawTracks
.map((e) => TrackRef.fromJson(e as Map<String, dynamic>))
.toList();
if (tracks.isEmpty) return;
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,
);
} catch (e, st) {
debugPrint('resume_controller: restore failed: $e\n$st');
}
}
@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<ResumeController>((ref) {
final c = ResumeController(ref);
ref.onDispose(c.dispose);
// ignore: unawaited_futures
c.start();
return c;
});