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>
This commit is contained in:
2026-05-18 15:57:58 -04:00
parent c80dc0b306
commit 41dd2892e3
6 changed files with 237 additions and 1 deletions
+6
View File
@@ -6,6 +6,7 @@ import 'cache/metadata_prefetcher.dart';
import 'cache/offline_provider.dart';
import 'cache/mutation_queue.dart';
import 'cache/prefetcher.dart';
import 'cache/resume_controller.dart';
import 'cache/sync_controller.dart';
import 'player/play_events_reporter.dart';
import 'shared/live_events_dispatcher.dart';
@@ -62,6 +63,11 @@ 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);
// Resume-on-launch (#54): restores the last persisted session
// (paused) and persists queue/index/position on track change,
// pause, and app teardown. Pairs with the #52 idle teardown so a
// torn-down session is recoverable instead of lost.
ref.read(resumeControllerProvider);
// Offline marker (#427 S1): periodic /healthz reachability
// probe → offlineProvider. Read here to start the poller; S4
// gates system-playlist play + Shuffle-all on it.
+22 -1
View File
@@ -230,6 +230,20 @@ class CachedQuarantineMine extends Table {
Set<Column> get primaryKey => {trackId};
}
/// Single-row snapshot of the last playback session — queue (TrackRef
/// JSON), current index, position, and #415 source. Lets a torn-down
/// session (the #52 idle/dismissed teardown) resume on next launch;
/// without it the headset / lock-screen play button has nothing to
/// resume. Mirrors the CachedHomeSnapshot single-row JSON pattern.
/// Schema 10+.
class CachedResumeState extends Table {
IntColumn get id => integer().withDefault(const Constant(1))();
TextColumn get json => text()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
@DriftDatabase(tables: [
@@ -247,12 +261,13 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
CachedHomeIndex,
CachedSystemPlaylistsStatus,
CachedMutations,
CachedResumeState,
])
class AppDb extends _$AppDb {
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
@override
int get schemaVersion => 9;
int get schemaVersion => 10;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -316,6 +331,12 @@ class AppDb extends _$AppDb {
'UPDATE audio_cache_index SET last_played_at = cached_at',
);
}
if (from < 10) {
// Schema 10 (#54): cached_resume_state — single-row last-
// session snapshot for resume-on-launch. Empty on upgrade;
// first persist populates it.
await m.createTable(cachedResumeState);
}
},
);
}
+159
View File
@@ -0,0 +1,159 @@
// 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;
});
+15
View File
@@ -40,4 +40,19 @@ class TrackRef {
durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0,
streamUrl: j['stream_url'] as String? ?? '',
);
/// Round-trips through [TrackRef.fromJson] (same server snake_case
/// keys). Used to persist the playback queue for resume-on-launch.
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'album_id': albumId,
'album_title': albumTitle,
'artist_id': artistId,
'artist_name': artistName,
if (trackNumber != null) 'track_number': trackNumber,
if (discNumber != null) 'disc_number': discNumber,
'duration_sec': durationSec,
'stream_url': streamUrl,
};
}
@@ -127,6 +127,11 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
String? _queueSource;
String? get queueSource => _queueSource;
/// The full TrackRef list backing the current queue (even when only
/// part is built as just_audio sources). Empty when nothing is
/// queued. Exposed for resume-state persistence (#54).
List<TrackRef> get queuedTracks => _lastTracks;
/// Volume stream for UI subscribers. Mirrors the just_audio player's
/// volume directly; set via setVolume(double).
Stream<double> get volumeStream => _player.volumeStream;
@@ -101,6 +101,36 @@ class PlayerActions {
await h.play();
}
/// Rebuilds a previously-persisted queue WITHOUT auto-playing, then
/// seeks to [position]. The resume-on-launch path (#54): the user
/// sees their last track in the mini bar, paused, and continues with
/// play / a media button. Mirrors playTracks' configure step so
/// streaming works after restore; intentionally no h.play().
Future<void> restoreQueue(
List<TrackRef> tracks, {
int initialIndex = 0,
Duration position = Duration.zero,
String? source,
}) async {
if (tracks.isEmpty) return;
final url = await _ref.read(serverUrlProvider.future);
final token = await _ref.read(secureStorageProvider).read(key: 'session_token');
final cache = _ref.read(albumCoverCacheProvider);
final audioCache = _ref.read(audioCacheManagerProvider);
final h = _ref.read(audioHandlerProvider)
..configure(
baseUrl: url ?? '',
token: token,
coverCache: cache,
audioCacheManager: audioCache,
likeBridge: _buildLikeBridge(),
);
await h.setQueueFromTracks(tracks, initialIndex: initialIndex, source: source);
if (position > Duration.zero) {
await h.seek(position);
}
}
/// Builds the adapter the audio handler uses to read + flip the
/// current track's like state in response to external media-
/// controller events (Wear's heart button, lock-screen favorite).