94871437dd
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>
184 lines
6.4 KiB
Dart
184 lines
6.4 KiB
Dart
// 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);
|
|
// 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);
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
|
|
/// Launch path: restore the last session PAUSED (no auto-blast).
|
|
Future<void> _restore() async {
|
|
try {
|
|
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
|
|
// 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;
|
|
});
|