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:
@@ -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.
|
||||
|
||||
Vendored
+22
-1
@@ -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
@@ -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;
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user