feat(player): resume on media-button when session torn down (#448)
Fast-follow of #54. After the #52 idle/dismiss teardown, pressing play on a headset / watch / lock screen did nothing (handler.play() was just _player.play() with nothing loaded; the handler is Riverpod-agnostic). - audio_handler: _resumeHook + setResumeHook(); play() is now async — when mediaItem == null and a hook is set it awaits the hook (which restores + plays) and returns, else _player.play() - resume_controller: extract shared _loadAndRestore() (bool); _restore() keeps the paused launch path; new resumeFromMediaButton() restores then starts playback; start() registers it via setResumeHook Recursion-safe (post-restore mediaItem != null so the re-play hits _player.play()); no-op when nothing to resume / auth missing / a session is already active. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+53
-29
@@ -45,6 +45,9 @@ class ResumeController with WidgetsBindingObserver {
|
|||||||
await _restore();
|
await _restore();
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
final h = _ref.read(audioHandlerProvider);
|
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.mediaItem.listen((_) => _schedulePersist()));
|
||||||
_subs.add(h.playbackState.listen((_) => _schedulePersist()));
|
_subs.add(h.playbackState.listen((_) => _schedulePersist()));
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
@@ -88,42 +91,63 @@ class ResumeController with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Launch path: restore the last session PAUSED (no auto-blast).
|
||||||
Future<void> _restore() async {
|
Future<void> _restore() async {
|
||||||
try {
|
try {
|
||||||
final h = _ref.read(audioHandlerProvider);
|
await _loadAndRestore();
|
||||||
// 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) {
|
} catch (e, st) {
|
||||||
debugPrint('resume_controller: restore failed: $e\n$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<void> 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<bool> _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<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 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
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
// App backgrounded / killed: persist durably right now (the debounce
|
// App backgrounded / killed: persist durably right now (the debounce
|
||||||
|
|||||||
@@ -173,6 +173,13 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
|
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<void> Function()? _resumeHook;
|
||||||
|
void setResumeHook(Future<void> Function() hook) => _resumeHook = hook;
|
||||||
|
|
||||||
Future<void> setQueueFromTracks(
|
Future<void> setQueueFromTracks(
|
||||||
List<TrackRef> tracks, {
|
List<TrackRef> tracks, {
|
||||||
int initialIndex = 0,
|
int initialIndex = 0,
|
||||||
@@ -598,7 +605,18 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> play() => _player.play();
|
Future<void> 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
|
@override
|
||||||
Future<void> pause() => _player.pause();
|
Future<void> pause() => _player.pause();
|
||||||
|
|||||||
Reference in New Issue
Block a user