d1e276204e
External media controllers (Android Wear, Auto, Bluetooth dashes, lock-screen widgets) consume the audio_service MediaSession and silently no-op on any action that isn't in the handler's systemActions set. Several handler methods were already implemented but never advertised, plus stop() defaulted to a no-op — which matched user reports of "media controller on the watch sometimes works but doesn't play nice with Minstrel." This patch lines the advertised surface up with what the handler actually implements + wires a native heart-rating button. **Expanded controls + systemActions:** - Added MediaControl.stop to the expanded controls list. - systemActions now also enumerates stop, skipToQueueItem (override shipped in v2026.05.13.1), setShuffleMode, setRepeatMode, and setRating. Without these in the set, Android 13+ drops the corresponding callbacks from external surfaces. **stop() override:** halts _player and dismisses the foreground notification via super.stop(). Default just flipped processingState to idle without releasing the audio session — external surfaces treated that as "paused forever". **setRating wiring (native heart-button protocol):** new LikeBridge adapter passes through configure() carrying toggleTrackLike + isTrackLiked closures over LikesController and likedIdsProvider. - setRating override flips the like through the bridge and re-emits mediaItem so the watch's heart icon updates immediately. - _toMediaItem populates MediaItem.rating on every track change so the right filled/outlined heart shows on track-A → track-B. - PlayerActions ref.listen on likedIdsProvider calls refreshCurrentRating so toggling a like from TrackRow / kebab / another device (SSE) also keeps the watch icon in sync. **artUri seed on first broadcast:** AlbumCoverCache.peekCached returns the file path synchronously when the cover is already on disk. _toMediaItem uses this so warm-cache tracks broadcast with artUri populated from the first frame — external controllers see the cover immediately instead of waiting for the later async _loadArtForCurrentItem path. Cold-cache tracks fall through to that path unchanged.
148 lines
5.4 KiB
Dart
148 lines
5.4 KiB
Dart
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}) async {
|
|
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);
|
|
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));
|