feat(player): notification favorite via MediaControl.custom (6c)

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>
This commit is contained in:
2026-05-18 18:37:20 -04:00
parent 5d37616d52
commit 0d80a113fa
4 changed files with 69 additions and 1 deletions
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M16.5,3c-1.74,0 -3.41,0.81 -4.5,2.09C10.91,3.81 9.24,3 7.5,3 4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.54L12,21.35l1.45,-1.32C18.6,15.36 22,12.28 22,8.5 22,5.42 19.58,3 16.5,3zM12.1,18.55l-0.1,0.1 -0.1,-0.1C7.14,14.24 4,11.39 4,8.5 4,6.5 5.5,5 7.5,5c1.54,0 3.04,0.99 3.57,2.36h1.87C13.46,5.99 14.96,5 16.5,5c2,0 3.5,1.5 3.5,3.5 0,2.89 -3.14,5.74 -7.9,10.05z"/>
</vector>
@@ -637,6 +637,12 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
mediaItem.add(media.copyWith(rating: Rating.newHeartRating(liked)));
}
/// Re-broadcasts PlaybackState so the notification favorite control's
/// icon/label reflects a like toggled from elsewhere (TrackRow, kebab,
/// another device via SSE). Sibling to refreshCurrentRating, which
/// updates the Wear/lock heart via MediaItem.rating.
void refreshFavoriteControl() => _broadcastState(null);
@override
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
await _player
@@ -661,16 +667,56 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
await _player.setVolume(v.clamp(0.0, 1.0));
}
/// Handles the notification favorite control (and any future custom
/// actions). Toggles the current track's like via the LikeBridge,
/// then re-broadcasts so the heart icon/label flips. Signature
/// matches AudioHandler.customAction (Future<dynamic>).
@override
Future<dynamic> customAction(String name,
[Map<String, dynamic>? extras]) async {
if (name == 'minstrel.favorite') {
final media = mediaItem.value;
final bridge = _likeBridge;
if (media == null || bridge == null) return null;
try {
await bridge.toggleTrackLike(media.id);
} catch (_) {}
_broadcastState(null);
return null;
}
return super.customAction(name, extras);
}
// _broadcastState accepts a nullable event because the shuffle/repeat
// listeners don't have one — we just want to re-emit PlaybackState
// with up-to-date shuffleMode/repeatMode fields.
void _broadcastState(PlaybackEvent? event) {
final playing = _player.playing;
// Favorite control: only when a track is playing and the LikeBridge
// is wired. Icon/label toggle by current like state; tapping it
// routes to customAction('minstrel.favorite'). Implemented as a
// MediaControl.custom (NOT setRating — that path is broken upstream,
// audio_service #376, and regressed the Pixel Watch). Kept out of
// androidCompactActionIndices so the compact/lock view is unchanged.
final favTrackId = mediaItem.value?.id;
final favBridge = _likeBridge;
final showFav = favTrackId != null && favBridge != null;
final favLiked = favTrackId != null &&
favBridge != null &&
favBridge.isTrackLiked(favTrackId);
playbackState.add(PlaybackState(
controls: [
MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.skipToNext,
if (showFav)
MediaControl.custom(
androidIcon: favLiked
? 'drawable/ic_stat_favorite'
: 'drawable/ic_stat_favorite_border',
label: favLiked ? 'Unfavorite' : 'Favorite',
name: 'minstrel.favorite',
),
],
// androidCompactActionIndices tells the system which controls
// appear in the collapsed/lock-screen view. Without this, some
@@ -62,7 +62,9 @@ class PlayerActions {
// 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();
_ref.read(audioHandlerProvider)
..refreshCurrentRating()
..refreshFavoriteControl();
});
}
final Ref _ref;