diff --git a/flutter_client/lib/cache/resume_controller.dart b/flutter_client/lib/cache/resume_controller.dart index b0dce1aa..80878dd5 100644 --- a/flutter_client/lib/cache/resume_controller.dart +++ b/flutter_client/lib/cache/resume_controller.dart @@ -45,6 +45,9 @@ class ResumeController with WidgetsBindingObserver { 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); @@ -88,42 +91,63 @@ class ResumeController with WidgetsBindingObserver { } } + /// Launch path: restore the last session PAUSED (no auto-blast). 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, - ); + 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 diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index 540c061a..1b801160 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -173,6 +173,13 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl if (audioCacheManager != null) _audioCacheManager = audioCacheManager; } + /// Invoked by play() when nothing is loaded (mediaItem == null) so a + /// media-button press after the #52 teardown resumes the last + /// persisted session (#448). Registered by ResumeController.start(); + /// null until then (then it's a no-op fall-through to _player.play()). + Future Function()? _resumeHook; + void setResumeHook(Future Function() hook) => _resumeHook = hook; + Future setQueueFromTracks( List tracks, { int initialIndex = 0, @@ -598,7 +605,18 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl } @override - Future play() => _player.play(); + Future play() async { + // Nothing loaded (fresh, or torn down by the #52 idle/dismiss + // teardown): a media-button press resumes the last persisted + // session instead of no-op'ing (#448). The hook restores the queue + // and starts playback itself, so we return without calling + // _player.play() on an empty player. + if (mediaItem.value == null && _resumeHook != null) { + await _resumeHook!(); + return; + } + await _player.play(); + } @override Future pause() => _player.pause();