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((ref) { throw UnimplementedError('overridden in main()'); }); final albumCoverCacheProvider = Provider((ref) { return AlbumCoverCache( dioFactory: () => ref.read(dioProvider.future), ); }); final playbackStateProvider = StreamProvider( (ref) => ref.watch(audioHandlerProvider).playbackState, ); final mediaItemProvider = StreamProvider( (ref) => ref.watch(audioHandlerProvider).mediaItem, ); final queueProvider = StreamProvider>( (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( (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( (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>(likedIdsProvider, (_, next) { if (next.value == null) return; _ref.read(audioHandlerProvider) ..refreshCurrentRating() ..refreshFavoriteControl(); }); } final Ref _ref; Future playTracks( List 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 restoreQueue( List 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 playNext(TrackRef track) async { final h = _ref.read(audioHandlerProvider); await h.playNext(track); } Future enqueue(TrackRef track) async { final h = _ref.read(audioHandlerProvider); await h.enqueue(track); } /// Toggles shuffle on/off. Pre-existing audio_service convention. Future 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 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 setVolume(double v) async { await _ref.read(audioHandlerProvider).setVolume(v); } /// Fetches `/api/radio?seed_track=` and starts playing the /// returned track list (seed at index 0 + recommended picks). Future 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((ref) => PlayerActions(ref));