41dd2892e3
The #52 teardown clears the session when idle/dismissed, so the headset / lock-screen play button had nothing to resume and the user lost their place. - track.dart: toJson() (round-trips fromJson) - db.dart: CachedResumeState single-row snapshot, schema 9->10 + migration - audio_handler.dart: queuedTracks getter - player_provider.dart: restoreQueue() — configure + setQueueFromTracks + seek, no play (restores PAUSED) - resume_controller.dart: restores last snapshot paused on launch; persists {source,index,position_ms,tracks} debounced on track change / pause and immediately on app teardown; never clobbers the saved snapshot when the queue is empty so a teardown stays recoverable; restore gated on auth + no active session - app.dart: wired into postFrame db.g.dart regenerated by CI per project convention. Deferred fast-follow: media-button-when-fully-stopped re-init (needs a configure()-injected callback; tracked on #54). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
197 lines
7.2 KiB
Dart
197 lines
7.2 KiB
Dart
import 'dart:math' show Random;
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../api/endpoints/likes.dart' show LikeKind;
|
|
import '../api/endpoints/radio.dart';
|
|
import '../auth/auth_provider.dart';
|
|
import '../cache/audio_cache_manager.dart';
|
|
import '../library/library_providers.dart' show dioProvider;
|
|
import '../likes/likes_provider.dart';
|
|
import '../models/track.dart';
|
|
import 'album_cover_cache.dart';
|
|
import 'audio_handler.dart';
|
|
|
|
final audioHandlerProvider = Provider<MinstrelAudioHandler>((ref) {
|
|
throw UnimplementedError('overridden in main()');
|
|
});
|
|
|
|
final albumCoverCacheProvider = Provider<AlbumCoverCache>((ref) {
|
|
return AlbumCoverCache(
|
|
dioFactory: () => ref.read(dioProvider.future),
|
|
);
|
|
});
|
|
|
|
final playbackStateProvider = StreamProvider<PlaybackState>(
|
|
(ref) => ref.watch(audioHandlerProvider).playbackState,
|
|
);
|
|
|
|
final mediaItemProvider = StreamProvider<MediaItem?>(
|
|
(ref) => ref.watch(audioHandlerProvider).mediaItem,
|
|
);
|
|
|
|
final queueProvider = StreamProvider<List<MediaItem>>(
|
|
(ref) => ref.watch(audioHandlerProvider).queue,
|
|
);
|
|
|
|
/// Player volume in [0.0, 1.0]. Mirrors just_audio's player.volume;
|
|
/// modify via PlayerActions.setVolume(). Surfaced separately from
|
|
/// PlaybackState because audio_service's PlaybackState doesn't carry
|
|
/// volume — it's a player-side concern.
|
|
final volumeProvider = StreamProvider<double>(
|
|
(ref) => ref.watch(audioHandlerProvider).volumeStream,
|
|
);
|
|
|
|
/// Live playback position. just_audio emits at ~200ms cadence so seek
|
|
/// bars driven by this provider scrub smoothly. Don't use
|
|
/// PlaybackState.updatePosition for that — it only changes on state
|
|
/// transitions (play/pause/buffer/seek) and the bar would appear
|
|
/// frozen between events.
|
|
final positionProvider = StreamProvider<Duration>(
|
|
(ref) => ref.watch(audioHandlerProvider).positionStream,
|
|
);
|
|
|
|
class PlayerActions {
|
|
PlayerActions(this._ref) {
|
|
// Keep MediaItem.rating in sync with the drift-backed likedIds
|
|
// cache so external media surfaces (Wear's heart button, lock-
|
|
// screen favorite, Auto's like icon) reflect the current state
|
|
// even when the user toggles a like from somewhere other than
|
|
// the watch itself — e.g. tapping the heart on a TrackRow, the
|
|
// kebab menu, or another logged-in device propagating via SSE.
|
|
_ref.listen<AsyncValue<LikedIds>>(likedIdsProvider, (_, next) {
|
|
if (next.value == null) return;
|
|
_ref.read(audioHandlerProvider).refreshCurrentRating();
|
|
});
|
|
}
|
|
final Ref _ref;
|
|
|
|
Future<void> playTracks(
|
|
List<TrackRef> tracks, {
|
|
int initialIndex = 0,
|
|
bool shuffle = false,
|
|
String? source,
|
|
}) async {
|
|
// shuffle=true means "play this pool randomly, starting at a
|
|
// random track" (client-side; used for non-system surfaces that
|
|
// want shuffle). System playlists instead fetch a server-ordered
|
|
// list and pass source — no client shuffle, the order is already
|
|
// rotation-aware (#415).
|
|
var startAt = initialIndex;
|
|
if (shuffle && tracks.length > 1) {
|
|
startAt = Random().nextInt(tracks.length);
|
|
}
|
|
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: startAt, source: source);
|
|
if (shuffle) {
|
|
await h.setShuffleMode(AudioServiceShuffleMode.all);
|
|
}
|
|
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<void> restoreQueue(
|
|
List<TrackRef> 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).
|
|
/// Constructed here because the audio handler is initialized in
|
|
/// main.dart before the ProviderScope exists; passing the bridge
|
|
/// in via configure() keeps the handler Riverpod-agnostic while
|
|
/// still letting it call into LikesController + likedIdsProvider.
|
|
LikeBridge _buildLikeBridge() {
|
|
return LikeBridge(
|
|
toggleTrackLike: (id) =>
|
|
_ref.read(likesControllerProvider).toggle(LikeKind.track, id),
|
|
isTrackLiked: (id) =>
|
|
_ref.read(likedIdsProvider).value?.has(LikeKind.track, id) ?? false,
|
|
);
|
|
}
|
|
|
|
Future<void> playNext(TrackRef track) async {
|
|
final h = _ref.read(audioHandlerProvider);
|
|
await h.playNext(track);
|
|
}
|
|
|
|
Future<void> enqueue(TrackRef track) async {
|
|
final h = _ref.read(audioHandlerProvider);
|
|
await h.enqueue(track);
|
|
}
|
|
|
|
/// Toggles shuffle on/off. Pre-existing audio_service convention.
|
|
Future<void> toggleShuffle() async {
|
|
final h = _ref.read(audioHandlerProvider);
|
|
final state = await h.playbackState.first;
|
|
final next = state.shuffleMode == AudioServiceShuffleMode.none
|
|
? AudioServiceShuffleMode.all
|
|
: AudioServiceShuffleMode.none;
|
|
await h.setShuffleMode(next);
|
|
}
|
|
|
|
/// Cycles repeat mode: none → all → one → none.
|
|
Future<void> cycleRepeat() async {
|
|
final h = _ref.read(audioHandlerProvider);
|
|
final state = await h.playbackState.first;
|
|
final next = switch (state.repeatMode) {
|
|
AudioServiceRepeatMode.none => AudioServiceRepeatMode.all,
|
|
AudioServiceRepeatMode.all => AudioServiceRepeatMode.one,
|
|
_ => AudioServiceRepeatMode.none,
|
|
};
|
|
await h.setRepeatMode(next);
|
|
}
|
|
|
|
Future<void> setVolume(double v) async {
|
|
await _ref.read(audioHandlerProvider).setVolume(v);
|
|
}
|
|
|
|
/// Fetches `/api/radio?seed_track=<id>` and starts playing the
|
|
/// returned track list (seed at index 0 + recommended picks).
|
|
Future<void> startRadio(String trackId) async {
|
|
final dio = await _ref.read(dioProvider.future);
|
|
final tracks = await RadioApi(dio).seedTrack(trackId);
|
|
if (tracks.isEmpty) return;
|
|
await playTracks(tracks);
|
|
}
|
|
}
|
|
|
|
final playerActionsProvider = Provider<PlayerActions>((ref) => PlayerActions(ref));
|