d12afdad6e
Closes Fable #412 (For You force-refresh on play) and #413 (shuffle-on-play default for system playlists). Web (PlaylistCard.svelte + player/store.svelte.ts): - Drop the refreshForYou() call from the play handler. The daily 03:00 user-local snapshot is what plays now. Stops burning server compute on every press and stops swapping the playlist out from under the user. - Generalize the kebab affordance to render for any system playlist (was Discover-only). Adds "Refresh For You" as an explicit replacement so users can still force a regen when they want one. - Extend playQueue(tracks, startIndex, { shuffle? }) to Fisher-Yates the queue when shuffle:true. PlaylistCard passes shuffle:true for any non-null system_variant. Flutter (player_provider.dart + playlists/widgets/playlist_card.dart): - playTracks now accepts shuffle:bool. When true, picks a random starting index and enables AudioServiceShuffleMode.all after setQueueFromTracks. PlaylistCard passes shuffle:playlist.isSystem. User playlists keep linear order. Detail-screen play buttons are unchanged for now (follow-up if user requests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
164 lines
5.9 KiB
Dart
164 lines
5.9 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,
|
|
}) async {
|
|
// shuffle=true means "play this pool randomly, starting at a
|
|
// random track." Used for system playlists so the first track of
|
|
// a re-play within the day is different from the prior one.
|
|
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);
|
|
if (shuffle) {
|
|
await h.setShuffleMode(AudioServiceShuffleMode.all);
|
|
}
|
|
await h.play();
|
|
}
|
|
|
|
/// 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));
|