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();
|
||||
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<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,
|
||||
);
|
||||
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<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
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// App backgrounded / killed: persist durably right now (the debounce
|
||||
|
||||
Reference in New Issue
Block a user