From 90aa658445266611ca4d4516952ccea8e10000ca Mon Sep 17 00:00:00 2001 From: defsub Date: Mon, 13 Feb 2023 11:45:37 -0800 Subject: [PATCH] Updates for custom action support in Android 13 Supports fast forward, rewind, and stop internally without any API changes. Supports app defined custom actions with custom icons. --- audio_service/android/build.gradle | 3 +- .../ryanheise/audioservice/AudioService.java | 83 +++--- .../audioservice/AudioServicePlugin.java | 3 +- .../ryanheise/audioservice/MediaControl.java | 4 +- .../example/android/app/build.gradle | 4 +- .../example/lib/example_custom_action.dart | 241 ++++++++++++++++++ audio_service/lib/audio_service.dart | 16 +- .../lib/audio_service_platform_interface.dart | 9 + 8 files changed, 322 insertions(+), 41 deletions(-) create mode 100644 audio_service/example/lib/example_custom_action.dart diff --git a/audio_service/android/build.gradle b/audio_service/android/build.gradle index 07026728..b1432a98 100644 --- a/audio_service/android/build.gradle +++ b/audio_service/android/build.gradle @@ -27,7 +27,7 @@ project.getTasks().withType(JavaCompile) { apply plugin: 'com.android.library' android { - compileSdkVersion 31 + compileSdkVersion 33 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 @@ -35,6 +35,7 @@ android { } defaultConfig { minSdkVersion 16 + targetSdkVersion 33 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } lintOptions { diff --git a/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioService.java b/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioService.java index 93d088f7..dafae544 100644 --- a/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioService.java +++ b/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioService.java @@ -61,8 +61,9 @@ public class AudioService extends MediaBrowserServiceCompat { private static final int NOTIFICATION_ID = 1124; private static final int REQUEST_CONTENT_INTENT = 1000; public static final String NOTIFICATION_CLICK_ACTION = "com.ryanheise.audioservice.NOTIFICATION_CLICK"; - public static final String MEDIA_BUTTON_REWIND_ACTION = "com.ryanheise.audioservice.MEDIA_BUTTON_REWIND"; - public static final String MEDIA_BUTTON_FAST_FORWARD_ACTION = "com.ryanheise.audioservice.MEDIA_BUTTON_FAST_FORWARD_ACTION"; + public static final String CUSTOM_MEDIA_BUTTON_ACTION = "com.ryanheise.audioservice.CUSTOM_MEDIA_BUTTON"; + public static final String EXTRA_ACTION_CODE = "actionCode"; + public static final String EXTRA_CUSTOM_ACTION = "customAction"; private static final String BROWSABLE_ROOT_ID = "root"; private static final String RECENT_ROOT_ID = "recent"; // See the comment in onMediaButtonEvent to understand how the BYPASS keycodes work. @@ -436,25 +437,34 @@ NotificationCompat.Action createAction(String resource, String label, long actio } private boolean needCustomMediaControl(MediaControl control) { - return control.actionCode == PlaybackStateCompat.ACTION_FAST_FORWARD || - control.actionCode == PlaybackStateCompat.ACTION_REWIND; - } - - private String toCustomActionName(long actionCode) { - if (actionCode == PlaybackStateCompat.ACTION_FAST_FORWARD) { - return MEDIA_BUTTON_FAST_FORWARD_ACTION; - } - if (actionCode == PlaybackStateCompat.ACTION_REWIND) { - return MEDIA_BUTTON_REWIND_ACTION; + if (control.customAction != null && control.customAction.length() > 0) { + return true; } - return ""; + + // Android 13 changes MediaControl behavior as documented here: + // https://developer.android.com/about/versions/13/behavior-changes-13 + // The below actions will be added to slots 1-3, if included. + // 1 - ACTION_PLAY, ACTION_PLAY + // 2 - ACTION_SKIP_TO_PREVIOUS + // 3 - ACTION_SKIP_TO_NEXT + // Custom actions will use slots 2-5 if included. + // - ACTION_STOP + // - ACTION_FAST_FORWARD + // - ACTION_REWIND + return (Build.VERSION.SDK_INT >= 33 && + (control.actionCode == PlaybackStateCompat.ACTION_STOP || + control.actionCode == PlaybackStateCompat.ACTION_FAST_FORWARD || + control.actionCode == PlaybackStateCompat.ACTION_REWIND)); } - PlaybackStateCompat.CustomAction createCustomAction(String resource, String label, long actionCode) { - int iconId = getResourceId(resource); - String action = toCustomActionName(actionCode); + PlaybackStateCompat.CustomAction createCustomAction(MediaControl action) { + int iconId = getResourceId(action.icon); + Bundle extras = new Bundle(); + extras.putLong(EXTRA_ACTION_CODE, action.actionCode); + extras.putString(EXTRA_CUSTOM_ACTION, action.customAction); PlaybackStateCompat.CustomAction.Builder builder = - new PlaybackStateCompat.CustomAction.Builder(action, label, iconId); + new PlaybackStateCompat.CustomAction.Builder( + CUSTOM_MEDIA_BUTTON_ACTION, action.label, iconId).setExtras(extras); return builder.build(); } @@ -494,12 +504,8 @@ void setState(List actions, long actionBits, int[] compactActionIn this.nativeActions.clear(); this.customActions.clear(); for (MediaControl action : actions) { - if (Build.VERSION.SDK_INT >= 33 && needCustomMediaControl(action)) { - // Android 13 changes MediaControl behavior as documented here: - // https://developer.android.com/about/versions/13/behavior-changes-13 - // Generally speaking, play, pause, prev & next are handled based on state. - // Other media controls are only supported as custom actions. - customActions.add(createCustomAction(action.icon, action.label, action.actionCode)); + if (needCustomMediaControl(action)) { + customActions.add(createCustomAction(action)); } else { nativeActions.add(createAction(action.icon, action.label, action.actionCode)); } @@ -517,10 +523,8 @@ void setState(List actions, long actionBits, int[] compactActionIn .setState(getPlaybackState(), position, speed, updateTime) .setBufferedPosition(bufferedPosition); - if (Build.VERSION.SDK_INT >= 33) { - for (PlaybackStateCompat.CustomAction action : this.customActions) { - stateBuilder.addCustomAction(action); - } + for (PlaybackStateCompat.CustomAction action : this.customActions) { + stateBuilder.addCustomAction(action); } if (queueIndex != null) @@ -600,7 +604,7 @@ public int getPlaybackState() { private Notification buildNotification() { int[] compactActionIndices = this.compactActionIndices; if (compactActionIndices == null) { - compactActionIndices = new int[Math.min(MAX_COMPACT_ACTIONS, actions.size())]; + compactActionIndices = new int[Math.min(MAX_COMPACT_ACTIONS, nativeActions.size())]; for (int i = 0; i < compactActionIndices.length; i++) compactActionIndices[i] = i; } NotificationCompat.Builder builder = getNotificationBuilder(); @@ -1065,15 +1069,24 @@ public void onSetShuffleMode(int shuffleMode) { @Override public void onCustomAction(String action, Bundle extras) { if (listener == null) return; - switch (action) { - case MEDIA_BUTTON_FAST_FORWARD_ACTION: - listener.onFastForward(); - return; - case MEDIA_BUTTON_REWIND_ACTION: + if (CUSTOM_MEDIA_BUTTON_ACTION.equals(action)) { + long actionCode = extras.getLong(EXTRA_ACTION_CODE, -1); + String customAction = extras.getString(EXTRA_CUSTOM_ACTION); + if (customAction != null && customAction.length() > 0) { + listener.onCustomAction(customAction, Bundle.EMPTY); + } + else if (actionCode == PlaybackStateCompat.ACTION_STOP) { + listener.onStop(); + } else if (actionCode == PlaybackStateCompat.ACTION_REWIND) { listener.onRewind(); - return; + } else if (actionCode == PlaybackStateCompat.ACTION_FAST_FORWARD) { + listener.onFastForward(); + } else { + listener.onCustomAction(action, extras); + } + } else { + listener.onCustomAction(action, extras); } - listener.onCustomAction(action, extras); } @Override diff --git a/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java b/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java index f6cbfa70..c02dccba 100644 --- a/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java +++ b/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java @@ -869,8 +869,9 @@ public void onMethodCall(MethodCall call, Result result) { String resource = (String)rawControl.get("androidIcon"); String label = (String)rawControl.get("label"); long actionCode = 1 << ((Integer)rawControl.get("action")); + String customAction = (String)rawControl.get("customAction"); actionBits |= actionCode; - actions.add(new MediaControl(resource, label, actionCode)); + actions.add(new MediaControl(resource, label, actionCode, customAction)); } for (Integer rawSystemAction : rawSystemActions) { long actionCode = 1 << rawSystemAction; diff --git a/audio_service/android/src/main/java/com/ryanheise/audioservice/MediaControl.java b/audio_service/android/src/main/java/com/ryanheise/audioservice/MediaControl.java index 89b3d216..8bc89b57 100644 --- a/audio_service/android/src/main/java/com/ryanheise/audioservice/MediaControl.java +++ b/audio_service/android/src/main/java/com/ryanheise/audioservice/MediaControl.java @@ -4,11 +4,13 @@ public class MediaControl { public final String icon; public final String label; public final long actionCode; + public final String customAction; - public MediaControl(String icon, String label, long actionCode) { + public MediaControl(String icon, String label, long actionCode, String customAction) { this.icon = icon; this.label = label; this.actionCode = actionCode; + this.customAction = customAction; } @Override diff --git a/audio_service/example/android/app/build.gradle b/audio_service/example/android/app/build.gradle index 078858ab..6d20846d 100644 --- a/audio_service/example/android/app/build.gradle +++ b/audio_service/example/android/app/build.gradle @@ -25,7 +25,7 @@ apply plugin: 'com.android.application' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 31 + compileSdkVersion 33 lintOptions { disable 'InvalidPackage' @@ -34,7 +34,7 @@ android { defaultConfig { applicationId "com.ryanheise.audioserviceexample" minSdkVersion 21 - targetSdkVersion 31 + targetSdkVersion 33 versionCode flutterVersionCode.toInteger() versionName flutterVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/audio_service/example/lib/example_custom_action.dart b/audio_service/example/lib/example_custom_action.dart new file mode 100644 index 00000000..59656dea --- /dev/null +++ b/audio_service/example/lib/example_custom_action.dart @@ -0,0 +1,241 @@ +// ignore_for_file: public_member_api_docs + +// FOR MORE EXAMPLES, VISIT THE GITHUB REPOSITORY AT: +// +// https://github.com/ryanheise/audio_service +// +// This example implements a minimal audio handler that renders the current +// media item and playback state to the system notification and responds to 4 +// media actions: +// +// - play +// - pause +// - seek +// - stop +// +// To run this example, use: +// +// flutter run + +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:audio_service_example/common.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:rxdart/rxdart.dart'; + +// You might want to provide this using dependency injection rather than a +// global variable. +late AudioHandler _audioHandler; + +Future main() async { + _audioHandler = await AudioService.init( + builder: () => AudioPlayerHandler(), + config: const AudioServiceConfig( + androidNotificationChannelId: 'com.ryanheise.myapp.channel.audio', + androidNotificationChannelName: 'Audio playback', + androidNotificationOngoing: true, + ), + ); + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Custom Action Demo', + theme: ThemeData(primarySwatch: Colors.blue), + home: const MainScreen(), + ); + } +} + +class MainScreen extends StatelessWidget { + const MainScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Custom Action Demo'), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Show media item title + StreamBuilder( + stream: _audioHandler.mediaItem, + builder: (context, snapshot) { + final mediaItem = snapshot.data; + return Text(mediaItem?.title ?? ''); + }, + ), + // Play/pause/stop buttons. + StreamBuilder( + stream: _audioHandler.playbackState + .map((state) => state.playing) + .distinct(), + builder: (context, snapshot) { + final playing = snapshot.data ?? false; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _button(Icons.fast_rewind, _audioHandler.rewind), + if (playing) + _button(Icons.pause, _audioHandler.pause) + else + _button(Icons.play_arrow, _audioHandler.play), + _button(Icons.stop, _audioHandler.stop), + _button(Icons.fast_forward, _audioHandler.fastForward), + ], + ); + }, + ), + // A seek bar. + StreamBuilder( + stream: _mediaStateStream, + builder: (context, snapshot) { + final mediaState = snapshot.data; + return SeekBar( + duration: mediaState?.mediaItem?.duration ?? Duration.zero, + position: mediaState?.position ?? Duration.zero, + onChangeEnd: (newPosition) { + _audioHandler.seek(newPosition); + }, + ); + }, + ), + // Display the processing state. + StreamBuilder( + stream: _audioHandler.playbackState + .map((state) => state.processingState) + .distinct(), + builder: (context, snapshot) { + final processingState = + snapshot.data ?? AudioProcessingState.idle; + return Text( + "Processing state: ${describeEnum(processingState)}"); + }, + ), + ], + ), + ), + ); + } + + /// A stream reporting the combined state of the current media item and its + /// current position. + Stream get _mediaStateStream => + Rx.combineLatest2( + _audioHandler.mediaItem, + AudioService.position, + (mediaItem, position) => MediaState(mediaItem, position)); + + IconButton _button(IconData iconData, VoidCallback onPressed) => IconButton( + icon: Icon(iconData), + iconSize: 64.0, + onPressed: onPressed, + ); +} + +class MediaState { + final MediaItem? mediaItem; + final Duration position; + + MediaState(this.mediaItem, this.position); +} + +/// An [AudioHandler] for playing a single item. +class AudioPlayerHandler extends BaseAudioHandler with SeekHandler { + static final _item = MediaItem( + id: 'https://s3.amazonaws.com/scifri-episodes/scifri20181123-episode.mp3', + album: "Science Friday", + title: "A Salute To Head-Scratching Science", + artist: "Science Friday and WNYC Studios", + duration: const Duration(milliseconds: 5739820), + artUri: Uri.parse( + 'https://media.wnyc.org/i/1400/1400/l/80/1/ScienceFriday_WNYCStudios_1400.jpg'), + ); + + final _player = AudioPlayer(); + + /// Initialise our audio handler. + AudioPlayerHandler() { + // So that our clients (the Flutter UI and the system notification) know + // what state to display, here we set up our audio handler to broadcast all + // playback state changes as they happen via playbackState... + _player.playbackEventStream.map(_transformEvent).pipe(playbackState); + // ... and also the current media item via mediaItem. + mediaItem.add(_item); + + // Load the player. + _player.setAudioSource(AudioSource.uri(Uri.parse(_item.id))); + } + + // In this simple example, we handle only 4 actions: play, pause, seek and + // stop. Any button press from the Flutter UI, notification, lock screen or + // headset will be routed through to these 4 methods so that you can handle + // your audio playback logic in one place. + + @override + Future play() => _player.play(); + + @override + Future pause() => _player.pause(); + + @override + Future seek(Duration position) => _player.seek(position); + + @override + Future stop() => _player.stop(); + + @override + Future customAction(String name, [Map? extras]) { + if (kDebugMode) { + print(name); + } + return super.customAction(name, extras); + } + + /// Transform a just_audio event into an audio_service state. + /// + /// This method is used from the constructor. Every event received from the + /// just_audio player will be transformed into an audio_service state so that + /// it can be broadcast to audio_service clients. + PlaybackState _transformEvent(PlaybackEvent event) { + return PlaybackState( + controls: [ + MediaControl.rewind, + if (_player.playing) MediaControl.pause else MediaControl.play, + MediaControl.stop, + MediaControl.fastForward, + MediaControl.custom(androidIcon: "drawable/ic_baseline_favorite_24", + label: "favorite", customAction: "favorite"), + ], + systemActions: const { + MediaAction.seek, + MediaAction.seekForward, + MediaAction.seekBackward, + }, + androidCompactActionIndices: const [0, 1], + processingState: const { + ProcessingState.idle: AudioProcessingState.idle, + ProcessingState.loading: AudioProcessingState.loading, + ProcessingState.buffering: AudioProcessingState.buffering, + ProcessingState.ready: AudioProcessingState.ready, + ProcessingState.completed: AudioProcessingState.completed, + }[_player.processingState]!, + playing: _player.playing, + updatePosition: _player.position, + bufferedPosition: _player.bufferedPosition, + speed: _player.speed, + queueIndex: event.currentIndex, + ); + } +} diff --git a/audio_service/lib/audio_service.dart b/audio_service/lib/audio_service.dart index 7eb3bcba..c2445b8a 100644 --- a/audio_service/lib/audio_service.dart +++ b/audio_service/lib/audio_service.dart @@ -25,7 +25,7 @@ enum MediaButton { previous, } -/// The actons associated with playing audio. +/// The actions associated with playing audio. enum MediaAction { /// Stop playing audio. stop, @@ -102,6 +102,9 @@ enum MediaAction { /// Set speed. setSpeed, + + /// Custom MediaAction. + custom, } /// The states of audio processing. @@ -843,17 +846,28 @@ class MediaControl { /// The action to be executed by this control final MediaAction action; + /// The action name to receive in [MediaAction.customAction] + final String? customAction; + + /// Creates a custom [MediaControl]. + MediaControl.custom({ + required this.androidIcon, + required this.label, + required this.customAction}) : action = MediaAction.custom; + /// Creates a custom [MediaControl]. const MediaControl({ required this.androidIcon, required this.label, required this.action, + this.customAction }); MediaControlMessage _toMessage() => MediaControlMessage( androidIcon: androidIcon, label: label, action: MediaActionMessage.values[action.index], + customAction: customAction, ); @override diff --git a/audio_service_platform_interface/lib/audio_service_platform_interface.dart b/audio_service_platform_interface/lib/audio_service_platform_interface.dart index 464e0f10..5e1c9aa0 100644 --- a/audio_service_platform_interface/lib/audio_service_platform_interface.dart +++ b/audio_service_platform_interface/lib/audio_service_platform_interface.dart @@ -260,6 +260,9 @@ enum MediaActionMessage { seekBackward, seekForward, setSpeed, + + /// This media action should be used for custom actions. + custom, } class MediaControlMessage { @@ -273,17 +276,23 @@ class MediaControlMessage { /// The action to be executed by this control final MediaActionMessage action; + /// The action string used in [customAction] when the action occurs. This should + /// be used along with [MediaAction.custom]. + final String? customAction; + @literal const MediaControlMessage({ required this.androidIcon, required this.label, required this.action, + this.customAction }); Map toMap() => { 'androidIcon': androidIcon, 'label': label, 'action': action.index, + if (customAction != null) 'customAction': customAction }; }