0d80a113fa
Adds a heart action to the media notification implemented as a custom control + customAction handler — NOT setRating, which is broken upstream (audio_service #376: onSetRating never fires from a notification tap) and previously blanked the Pixel Watch. - res/drawable/ic_stat_favorite{,_border}.xml: white 24dp vector hearts - audio_handler: favorite MediaControl.custom in _broadcastState (icon/label toggle by LikeBridge state; kept out of androidCompactActionIndices so compact/lock + Wear transport are unchanged); customAction override (Future<dynamic>, matches base) toggles the like then re-broadcasts; refreshFavoriteControl() - player_provider: cascade refreshFavoriteControl into the likedIds listener so liking from TrackRow/kebab/SSE flips the notification heart Reliable on phone notification + lock screen; Wear/Auto display of a non-transport custom action is platform-dependent (not a bug). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
199 lines
7.3 KiB
Dart
199 lines
7.3 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()
|
|
..refreshFavoriteControl();
|
|
});
|
|
}
|
|
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));
|