diff --git a/flutter_client/lib/app.dart b/flutter_client/lib/app.dart index 93a6684c..a963d718 100644 --- a/flutter_client/lib/app.dart +++ b/flutter_client/lib/app.dart @@ -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 { // 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. diff --git a/flutter_client/lib/cache/db.dart b/flutter_client/lib/cache/db.dart index 824adcb9..c46aaacd 100644 --- a/flutter_client/lib/cache/db.dart +++ b/flutter_client/lib/cache/db.dart @@ -230,6 +230,20 @@ class CachedQuarantineMine extends Table { Set 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 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); + } }, ); } diff --git a/flutter_client/lib/cache/resume_controller.dart b/flutter_client/lib/cache/resume_controller.dart new file mode 100644 index 00000000..b0dce1aa --- /dev/null +++ b/flutter_client/lib/cache/resume_controller.dart @@ -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 = >[]; + 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); + _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'); + } + } + + Future _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; + final rawTracks = (m['tracks'] as List?) ?? const []; + final tracks = rawTracks + .map((e) => TrackRef.fromJson(e as Map)) + .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((ref) { + final c = ResumeController(ref); + ref.onDispose(c.dispose); + // ignore: unawaited_futures + c.start(); + return c; +}); diff --git a/flutter_client/lib/models/track.dart b/flutter_client/lib/models/track.dart index 8c270776..1e5adc58 100644 --- a/flutter_client/lib/models/track.dart +++ b/flutter_client/lib/models/track.dart @@ -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 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, + }; } diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index 4fd94fda..a2713d98 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -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 get queuedTracks => _lastTracks; + /// Volume stream for UI subscribers. Mirrors the just_audio player's /// volume directly; set via setVolume(double). Stream get volumeStream => _player.volumeStream; diff --git a/flutter_client/lib/player/player_provider.dart b/flutter_client/lib/player/player_provider.dart index c1134aba..91a3e77b 100644 --- a/flutter_client/lib/player/player_provider.dart +++ b/flutter_client/lib/player/player_provider.dart @@ -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 restoreQueue( + List 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).