feat(player): add Mornye media notification controls

Sync notification favorites with Library, add Mornye transport icons and an output action on legacy Android, and expose the iOS favorite command. Preserve playback position when controls refresh.

Vendor audio_service 0.18.19 with a small Android patch for custom notification actions. Modern Android keeps its system output switcher and transport layout.
This commit is contained in:
zarzet committed 2026-09-26 03:07:38 +07:00
1 parent 728d179d69
commit 5259c86365
80 files changed
+8973 -13

No files matched your search

+447
View File
@@ -0,0 +1,447 @@
## 0.18.19
* Support AGP 9.
* Migrate Android build files to .kts
## 0.18.18
* Fix setPlaybackState entitlement issue on iOS.
* Fix Android stopForeground deprecation warning.
* Bump compile/target SDK to 35 on Android.
* Bump minSdk to 19 on Android.
* bump AGP to 8.5.2.
## 0.18.17
* Add support for SwiftPM.
## 0.18.16
* Support MPNowPlayingInfoPropertyIsLiveStream on IOS (@MuradSh, @celsoft).
## 0.18.15
* Add deep link support for FlutterFragmentActivity (@jan-milovanovic).
* Fix JDK 21 compile error.
## 0.18.14
* Support rxdart 0.28.x.
## 0.18.13
* Fix Android NPE in sendNotificationClicked and onConnected.
* Catch and broadcast async platform exceptions.
* Support js '>=0.6.3 <0.8.0'
* Fix setAndroidPlaybackInfo call blocking (@julianscheel).
* Pass through missing extras to playFrom.../prepareFrom... (@Ruchit2759).
## 0.18.12
* Fix crash with Oppo/OnePlus devices running Android 13.
## 0.18.11
* Support custom media controls (@defsub)
* Support fast forward, rewind and stop when targeting Android 13 (@defsub)
## 0.18.10
* Add support for AGP 8 (@theskyblockman).
* Update AGP to 7.3.0.
* Update minimum flutter version to 3.0.
## 0.18.9
* Fix cache bug in AudioServiceFragmentActivity (@Mordtimer).
* Add Android Auto manifest entry for example app (@ColinSchmale).
## 0.18.8
* Improve efficiency of mediaItem updates (@nt4f04uNd).
## 0.18.7
* Fix stopForeground bug on Android SDK < 24.
* Migrate to androidx.media 1.6.0 (@snipd-mikel)
* Propagate MediaItem extras to Android Auto (@snipd-mikel)
* Update progress bar in Android Auto (@snipd-mikel)
## 0.18.6
* Fix build when targeting Android 13.
* Add MediaItem.artHeaders.
## 0.18.5
* Add AudioServiceFragmentActivity (@deimantasa).
* Support `content://` art URIs in notification on Android (@nt4f04uNd).
* Document Android foregroundServiceType.
## 0.18.4
* Fix Android FlutterJNI error after quick relaunch.
* Fix Android NPE when destroying additional FlutterEngines.
## 0.18.3
* Fix build when targeting Android 12.
## 0.18.2
* Guard against NPE when Android service is destroyed quickly.
* Migrate to flutter_lints.
* Queue messages from platform if init() called late.
* Fix deep linking on Android (@vishna/@ryanheise).
## 0.18.1
* Remove iOS notification on stop.
* Fix setSpeed action on iOS.
* Eliminate redundant notification updates on Android.
* Handle null album and artist on web (@nt4f04uNd).
* Fix multithreaded crash in notification tap (@nt4f04uNd).
* Fix regression to show album art on lock screen (@nt4f04uNd).
* Add playlist/shuffle/loop example.
## 0.18.0
* Use a single isolate for easier communication.
* Replace BackgroundAudioTask by AudioHandler.
* Replace AudioService.start by AudioService.init.
* Android Auto support.
* Android 11 media session resumption support.
* Federated plugin model.
* Composable audio handlers (@yringler).
* More callbacks:
* prepareFromSearch
* prepareFromUri
* playFromSearch
* playFromUri
* addQueueItems
* removeQueueItemAt
* setCaptioningEnabled
* getMediaItem
* search
* androidSetRemoteVolume
* androidAdjustRemoteVolume
* More state:
* queueTitle
* ratingStyle
* androidPlaybackInfo
* customState
* Default platform implementation for Windows/Linux (@keaganhilliard)
* iOS/macOS control center bug fixes (@nt4f04uNd)
* Fix queue index out of bounds bug (@kcrebound)
* Fix bug when starting foreground service from background (@chengyuhui)
* Make MediaItem.album nullable (@letiagoalves)
* Code quality:
* Unit tests (@suragch, @nt4f04uNd)
* Strong-mode and pedantic lints, code consistency (@nt4f04uNd)
* Improve artUri performance on Android (@nt4f04uNd)
* Better detection of browser support (@nt4f04uNd)
## 0.17.1
* Support rxdart 0.27.0.
## 0.17.0
* Null safety.
* Change artUri type from String to Uri.
## 0.16.2+1
* Mention upcoming 0.18.0 release in README.
## 0.16.2
* Fix positionStream bug when seek is interrupted by onStop.
* Fix JS name clash for MediaMetadata.
* Update NowPlayingInfo speed correctly on iOS (@ryotayama).
## 0.16.1
* Fix bug in start() when using HttpOverrides.
## 0.16.0
* setState parameters default to previous state.
* Change updateTime from Duration to DateTime.
* Rename newStartRating to newStarRating.
* Declare type of MediaItem.extras (@hacker1024).
* Unit tests.
* Fix compile error on macOS.
* Update dependencies.
## 0.15.3
* Add positionStream and runningStream.
* Add androidShowNotificationBadge option (@aleexbt).
## 0.15.2
* Process connect/disconnect/start requests in a queue.
* Guard against null setState arguments.
* Range check in onSkipToPrevious (@snaeji).
## 0.15.1
* Fix loading of file:// artUri values.
* Allow booleans/doubles in MediaItems.
* Silently ignore duplicate onStop requests.
## 0.15.0
* Web support (@keaganhilliard)
* macOS support (@hacker1024)
* Route next/previous buttons to onClick on Android (@stonega)
* Correctly scale skip intervals for control center (@subhash279)
* Handle repeated stop/start calls more robustly.
* Fix Android 11 bugs.
## 0.14.1
* audio_session dependency now supports minSdkVersion 16 on Android.
## 0.14.0
* audio session management now handled by audio_session (see [Migration Guide](https://github.com/ryanheise/audio_service/wiki/Migration-Guide#0140)).
* Exceptions in background audio task are logged and forwarded to client.
## 0.13.0
* All BackgroundAudioTask callbacks are now async.
* Add default implementation of onSkipToNext/onSkipToPrevious.
* Bug fixes.
## 0.12.0
* Add setRepeatMode/setShuffleMode.
* Enable iOS Control Center buttons based on setState.
* Support seek forward/backward in iOS Control Center.
* Add default behaviour to BackgroundAudioTask.
* Bug fixes.
* Simplify example.
## 0.11.2
* Fix bug with album metadata on Android.
## 0.11.1
* Allow setting the iOS audio session category and options.
* Allow AudioServiceWidget to recognise swipe gesture on iOS.
* Check for null title and album on Android.
## 0.11.0
* Breaking change: onStop must await super.onStop to shutdown task.
* Fix Android memory leak.
## 0.10.0
* Replace androidStopOnRemoveTask with onTaskRemoved callback.
* Add onClose callback.
* Breaking change: new MediaButtonReceiver in AndroidManifest.xml.
## 0.9.0
* New state model: split into playing + processingState.
* androidStopForegroundOnPause ties foreground state to playing state.
* Add MediaItem.toJson/fromJson.
* Add AudioService.notificationClickEventStream (Android).
* Add AudioService.updateMediaItem.
* Add AudioService.setSpeed.
* Add PlaybackState.bufferedPosition.
* Add custom AudioService.start parameters.
* Rename replaceQueue -> updateQueue.
* Rename Android-specific start parameters with android- prefix.
* Use Duration type for all time values.
* Pass fastForward/rewind intervals through to background task.
* Allow connections from background contexts (e.g. android_alarm_manager).
* Unify iOS/Android focus APIs.
* Bug fixes and dependency updates.
## 0.8.0
* Allow UI to await the result of custom actions.
* Allow background to broadcast custom events to UI.
* Improve memory management for art bitmaps on Android.
* Convenience methods: replaceQueue, playMediaItem, addQueueItems.
* Bug fixes and dependency updates.
## 0.7.2
* Shutdown background task if task killed by IO (Android).
* Bug fixes and dependency updates.
## 0.7.1
* Add AudioServiceWidget to auto-manage connections.
* Allow file URIs for artUri.
## 0.7.0
* Support skip forward/backward in command center (iOS).
* Add 'extras' field to MediaItem.
* Artwork caching and preloading supported on Android+iOS.
* Bug fixes.
## 0.6.2
* Bug fixes.
## 0.6.1
* Option to stop service on closing task (Android).
## 0.6.0
* Migrated to V2 embedding API (Flutter 1.12).
## 0.5.7
* Destroy isolates after use.
## 0.5.6
* Support Flutter 1.12.
## 0.5.5
* Bump sdk version to 2.6.0.
## 0.5.4
* Fix Android memory leak.
## 0.5.3
* Support Queue, album art and other missing features on iOS.
## 0.5.2
* Update documentation and example.
## 0.5.1
* Playback state broadcast on connect (iOS).
## 0.5.0
* Partial iOS support.
## 0.4.2
* Option to call stopForeground on pause.
## 0.4.1
* Fix queue support bug
## 0.4.0
* Breaking change: AudioServiceBackground.run takes a single parameter.
## 0.3.1
* Update example to disconnect when pressing back button.
## 0.3.0
* Breaking change: updateTime now measured since epoch instead of boot time.
## 0.2.1
* Streams use RxDart BehaviorSubject.
## 0.2.0
* Migrate to AndroidX.
## 0.1.1
* Bump targetSdkVersion to 28
* Clear client-side metadata and state on stop.
## 0.1.0
* onClick is now always called for media button clicks.
* Option to set notifications as ongoing.
## 0.0.15
* Option to set subText in notification.
* Support media item ratings
## 0.0.14
* Can update existing media items.
* Can specify order of Android notification compact actions.
* Bug fix with connect.
## 0.0.13
* Option to preload artwork.
* Allow client to browse media items.
## 0.0.12
* More options to customise the notification content.
## 0.0.11
* Breaking API changes.
* Connection callbacks replaced by a streams API.
* AudioService properties for playbackState, currentMediaItem, queue.
* Option to set Android notification channel description.
* AudioService.customAction awaits completion of the action.
## 0.0.10
* Bug fixes with queue management.
* AudioService.start completes when the background task is ready.
## 0.0.9
* Support queue management.
## 0.0.8
* Bug fix.
## 0.0.7
* onMediaChanged takes MediaItem parameter.
* Support playFromMediaId, fastForward, rewind.
## 0.0.6
* All APIs address media items by String mediaId.
## 0.0.5
* Show media art in notification and lock screen.
## 0.0.4
* Support and example for playing TextToSpeech.
* Click notification to launch UI.
* More properties added to MediaItem.
* Minor API changes.
## 0.0.3
* Pause now keeps background isolate running
* Notification channel id is generated from package name
* Updated example to use audioplayer plugin
* Fixed media button handling
## 0.0.2
* Better connection handling.
## 0.0.1
* Initial release.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018-2021 Ryan Heise and the project contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+425
View File
@@ -0,0 +1,425 @@
# audio_service
This plugin wraps around your existing audio code to allow it to run in the background and interact with the media notification, the lock screen, headset buttons, wearables and Android Auto. It supports Android, iOS, web and Linux (via [audio_service_mpris](https://pub.dev/packages/audio_service_mpris)). It is suitable for:
* Music players
* Text-to-speech readers
* Podcast players
* Video players
* Navigators
* More!
## How does this plugin work?
You encapsulate your audio code in an audio handler which implements a set of standard system callbacks to handle media playback requests from different sources in a uniform way:
![audio_handler](https://user-images.githubusercontent.com/19899190/100403242-762e7480-30b2-11eb-9fcf-938e08beee53.png)
You implement these callbacks to play the particular type of audio that your app needs to play in response to these requests. For example, a Text-to-speech reader app might implement these callbacks using [flutter_tts](https://pub.dartlang.org/packages/flutter_tts) to render the speech, while a music player app might implement these callbacks using [just_audio](https://pub.dartlang.org/packages/just_audio) to render the audio.
| Feature | Android | iOS | macOS | Web |
| ------- | :-------: | :-----: | :-----: | :-----: |
| background audio | ✅ | ✅ | ✅ | ✅ |
| headset clicks | ✅ | ✅ | ✅ | ✅ |
| play/pause/seek/rate/stop | ✅ | ✅ | ✅ | ✅ |
| fast forward/rewind | ✅ | ✅ | ✅ | ✅ |
| repeat/shuffle mode | ✅ | ✅ | ✅ | ✅ |
| queue manipulation, skip next/prev | ✅ | ✅ | ✅ | ✅ |
| custom actions/events/states | ✅ | ✅ | ✅ | ✅ |
| notifications/control center | ✅ | ✅ | ✅ | ✅ |
| lock screen controls | ✅ | ✅ | | ✅ |
| album art | ✅ | ✅ | ✅ | ✅ |
| Android Auto, Apple CarPlay | ✅ | ✅ | | |
If you'd like to help with any missing features, please join us on the [GitHub issues page](https://github.com/ryanheise/audio_service/issues).
## Tutorials and documentation
* [Background audio in Flutter with Audio Service and Just Audio](https://suragch.medium.com/background-audio-in-flutter-with-audio-service-and-just-audio-3cce17b4a7d?sk=0837a1b1773e27a4f879ff3072e90305) by @suragch
* [Tutorial](https://github.com/ryanheise/audio_service/wiki/Tutorial): walks you through building a simple audio player while explaining the basic concepts.
* [Full example](https://github.com/ryanheise/audio_service): The `example` subdirectory on GitHub demonstrates both music and text-to-speech use cases.
* [Frequently Asked Questions](https://github.com/ryanheise/audio_service/wiki/FAQ)
* [API documentation](https://pub.dev/documentation/audio_service/latest/audio_service/audio_service-library.html)
## Can I make use of other plugins within the audio handler?
Yes! `audio_service` is designed to let you implement the audio logic however you want, using whatever plugins you want. You can use your favourite audio plugins such as [just_audio](https://pub.dartlang.org/packages/just_audio), [flutter_tts](https://pub.dartlang.org/packages/flutter_tts), and others, within your audio handler. There are also plugins like [just_audio_handlers](https://github.com/yringler/inside-app/tree/master/just_audio_handlers) that provide default implementations of `AudioHandler` to make your job easier.
Note that this plugin will not work with other audio plugins that overlap in responsibility with this plugin (i.e. background audio, iOS control center, Android notifications, lock screen, headset buttons, etc.)
## Example
### Initialisation
Define your `AudioHandler` with the callbacks that you want your app to handle:
```dart
class MyAudioHandler extends BaseAudioHandler
with QueueHandler, // mix in default queue callback implementations
SeekHandler { // mix in default seek callback implementations
final _player = AudioPlayer(); // e.g. just_audio
// The most common callbacks:
Future<void> play() => _player.play();
Future<void> pause() => _player.pause();
Future<void> stop() => _player.stop();
Future<void> seek(Duration position) => _player.seek(position);
Future<void> skipToQueueItem(int i) => _player.seek(Duration.zero, index: i);
}
```
Register your `AudioHandler` during app startup:
```dart
Future<void> main() async {
// store this in a singleton
_audioHandler = await AudioService.init(
builder: () => MyAudioHandler(),
config: AudioServiceConfig(
androidNotificationChannelId: 'com.mycompany.myapp.channel.audio',
androidNotificationChannelName: 'Music playback',
),
);
runApp(new MyApp());
}
```
### Sending requests to the audio handler from Flutter
Standard controls:
```dart
_audioHandler.play();
_audioHandler.seek(Duration(seconds: 10));
_audioHandler.setSpeed(1.5);
_audioHandler.pause();
_audioHandler.stop();
```
Playing specific media items:
```dart
var item = MediaItem(
id: 'https://example.com/audio.mp3',
album: 'Album name',
title: 'Track title',
artist: 'Artist name',
duration: const Duration(milliseconds: 123456),
artUri: Uri.parse('https://example.com/album.jpg'),
);
_audioHandler.playMediaItem(item);
_audioHandler.playFromSearch(queryString);
_audioHandler.playFromUri(uri);
_audioHandler.playFromMediaId(id);
```
Queue management:
```dart
_audioHandler.addQueueItem(item);
_audioHandler.insertQueueItem(1, item);
_audioHandler.removeQueueItem(item);
_audioHandler.updateQueue([item, ...]);
_audioHandler.skipToNext();
_audioHandler.skipToPrevious();
_audioHandler.skipToQueueItem(2);
```
Looping and shuffling:
```dart
_audioHandler.setRepeatMode(AudioServiceRepeatMode.one); // none/one/all/group
_audioHandler.setShuffleMode(AudioServiceShuffleMode.all); // none/all/group
```
Custom actions:
```dart
_audioHandler.customAction('setVolume', {'volume': 0.8});
_audioHandler.customAction('saveBookmark');
```
### Broadcasting state changes
Your audio handler must broadcast state changes so that the system notification and smart watches (etc) know what state to display. Your app's Flutter UI may also listen to these state changes so that it knows what state to display. Thus, the audio handler provides a single source of truth for your audio state to all clients.
Broadcast the current media item:
```dart
class MyAudioHandler extends BaseAudioHandler ... {
...
mediaItem.add(item1);
...
```
Broadcast the current queue:
```dart
...
queue.add(<MediaItem>[item1, item2, item3]);
...
```
Broadcast the current playback state:
```dart
...
// All options shown:
playbackState.add(PlaybackState(
// Which buttons should appear in the notification now
controls: [
MediaControl.skipToPrevious,
MediaControl.pause,
MediaControl.stop,
MediaControl.skipToNext,
],
// Which other actions should be enabled in the notification
systemActions: const {
MediaAction.seek,
MediaAction.seekForward,
MediaAction.seekBackward,
},
// Which controls to show in Android's compact view.
androidCompactActionIndices: const [0, 1, 3],
// Whether audio is ready, buffering, ...
processingState: AudioProcessingState.ready,
// Whether audio is playing
playing: true,
// The current position as of this update. You should not broadcast
// position changes continuously because listeners will be able to
// project the current position after any elapsed time based on the
// current speed and whether audio is playing and ready. Instead, only
// broadcast position updates when they are different from expected (e.g.
// buffering, or seeking).
updatePosition: Duration(milliseconds: 54321),
// The current buffered position as of this update
bufferedPosition: Duration(milliseconds: 65432),
// The current speed
speed: 1.0,
// The current queue position
queueIndex: 0,
));
```
Broadcasting mutations of the current playback state using `copyWith`:
```dart
playbackState.add(playbackState.value.copyWith(
// Keep all existing state the same with only the speed changed:
speed: newSpeed,
));
```
### Listening to state changes
Listen to changes to the currently playing item from the Flutter UI:
```dart
_audioHandler.mediaItem.listen((MediaItem item) { ... });
```
Listen to changes to the queue from the Flutter UI:
```dart
_audioHandler.queue.listen((List<MediaItem> queue) { ... });
```
Listen to playback state changes from the Flutter UI:
```dart
_audioHandler.playbackState.listen((PlaybackState state) {
if (state.playing) ... else ...
switch (state.processingState) {
case AudioProcessingState.idle: ...
case AudioProcessingState.loading: ...
case AudioProcessingState.buffering: ...
case AudioProcessingState.ready: ...
case AudioProcessingState.completed: ...
case AudioProcessingState.error: ...
}
});
```
Listen to a stream of continuous changes to the current playback position:
```dart
AudioService.position.listen((Duration position) { ... });
```
### Advanced features
Compose multiple audio handler classes:
```dart
_audioHandler = await AudioService.init(
builder: () => AnalyticsAudioHandler(
PersistingAudioHandler(
MyAudioHandler())),
);
```
Connecting from another isolate:
```dart
// Wrap audio handler in IsolatedAudioHandler:
_audioHandler = await AudioService.init(
builder: () => IsolatedAudioHandler(
MyAudioHandler(),
portName: 'my_audio_handler',
),
);
// From another isolate, obtain a proxy reference:
_proxyAudioHandler = await IsolatedAudioHandler.lookup(
portName: 'my_audio_handler',
);
```
See the full example for how to handle queues/playlists, headset button clicks, media artwork and text to speech.
## Configuring the audio session
If your app uses audio, you should tell the operating system what kind of usage scenario your app has and how your app will interact with other audio apps on the device. Different audio apps often have unique requirements. For example, when a navigator app speaks driving instructions, a music player should duck its audio while a podcast player should pause its audio. Depending on which one of these three apps you are building, you will need to configure your app's audio settings and callbacks to appropriately handle these interactions.
Use the [audio_session](https://pub.dev/packages/audio_session) package to change the default audio session configuration for your app. E.g. for a podcast player, you may use:
```dart
final session = await AudioSession.instance;
await session.configure(AudioSessionConfiguration.speech());
```
Each time you invoke an audio plugin to play audio, that plugin will activate your app's shared audio session to inform the operating system that your app is actively playing audio. Depending on the configuration set above, this will also inform other audio apps to either stop playing audio, or possibly continue playing at a lower volume (i.e. ducking). You normally do not need to activate the audio session yourself, however if the audio plugin you use does not activate the audio session, you can activate it yourself:
```dart
// Activate the audio session before playing audio.
if (await session.setActive(true)) {
// Now play audio.
} else {
// The request was denied and the app should not play audio
}
```
When another app activates its audio session, it similarly may ask your app to pause or duck its audio. Once again, the particular audio plugin you use may automatically pause or duck audio when requested. However, if it does not, you can respond to these events yourself by listening to `session.interruptionEventStream`. Similarly, if the audio plugin doesn't handle unplugged headphone events, you can respond to these yourself by listening to `session.becomingNoisyEventStream`. For more information, consult the documentation for [audio_session](https://pub.dev/packages/audio_session).
Note: If your app uses a number of different audio plugins, e.g. for audio recording, or text to speech, or background audio, it is possible that those plugins may internally override each other's audio session settings since there is only a single audio session shared by your app. Therefore, it is recommended that you apply your own preferred configuration using audio_session after all other audio plugins have loaded. You may consider asking the developer of each audio plugin you use to provide an option to not overwrite these global settings and allow them be managed externally.
## Android setup
1. Make the following changes to your project's `AndroidManifest.xml` file:
```xml
<manifest xmlns:tools="http://schemas.android.com/tools" ...>
<!-- ADD THESE TWO PERMISSIONS -->
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<!-- ALSO ADD THIS PERMISSION IF TARGETING SDK 34 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<application ...>
...
<!-- EDIT THE android:name ATTRIBUTE IN YOUR EXISTING "ACTIVITY" ELEMENT -->
<activity android:name="com.ryanheise.audioservice.AudioServiceActivity" ...>
...
</activity>
<!-- ADD THIS "SERVICE" element -->
<service android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true" tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<!-- ADD THIS "RECEIVER" element -->
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true" tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
</application>
</manifest>
```
Note: As of Android 12, an app must have permission to restart a foreground service in the background, otherwise a `ForegroundServiceStartNotAllowedException` will be thrown. To avoid such an exception, you can either set `androidStopForegroundOnPause` to `false` in your `AudioServiceConfig` which keeps the service in the foreground during a pause so that restarting the foreground service is unnecessary, or you can keep the default `androidStopForegroundOnPause` setting of `true` (in line with best practices) and request the user to turn of battery optimisation for your app via the [optimize_battery](https://pub.dev/packages/optimize_battery) package. For more information, read [this page](https://developer.android.com/training/monitoring-device-state/doze-standby#support_for_other_use_cases).
2. If you use any custom icons in notification, create the file `android/app/src/main/res/raw/keep.xml` to prevent them from being stripped during the build process:
```xml
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/*" />
```
By default plugin's default icons are not stripped by R8. If you don't use them, you may selectively strip them. For example, the rules below will keep all your icons and discard all the plugin's:
```xml
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/*"
tools:discard="@drawable/audio_service_*"
/>
```
For more information about shrinking see [Android documentation](https://developer.android.com/studio/build/shrink-code#keep-resources).
### Custom Android activity
If your app needs to use its own custom activity, make sure you update your `AndroidManifest.xml` file to reference your activity's class name instead of `AudioServiceActivity`. For example, if your activity class is named `MainActivity`, then use:
```xml
<activity android:name=".MainActivity" ...>
```
Depending on whether you activity is a regular `Activity` or a `FragmentActivity`, you must also include some code to link to audio_service's shared `FlutterEngine`. The easiest way to accomplish this is to inherit that code from one of audio_service's provided base classes.
1. Integration as an `Activity`:
```java
import com.ryanheise.audioservice.AudioServiceActivity;
class MainActivity extends AudioServiceActivity {
// ...
}
```
2. Integration as a `FragmentActivity`:
```java
import com.ryanheise.audioservice.AudioServiceFragmentActivity;
class MainActivity extends AudioServiceFragmentActivity {
// ...
}
```
You can also write your own activity class from scratch, and override the `provideFlutterEngine`, `getCachedEngineId` and `shouldDestroyEngineWithHost` methods yourself. For inspiration, see the source code of the provided `AudioServiceActivity` and `AudioServiceFragmentActivity` classes.
## iOS setup
Insert this in your `Info.plist` file:
```
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
```
The example project may be consulted for context.
Note that the `audio` background mode permits an app to run in the background only for the purpose of playing audio. The OS may kill your process if it sits idly without playing audio, for example, by using a timer to sleep for a few seconds. If your app needs to pause for a few seconds between audio tracks, consider playing a silent audio track to create that effect rather than using an idle timer.
## macOS setup
The minimum supported macOS version is 10.12.2 (though this could be changed with some work in the future).
Modify the platform line in `macos/Podfile` to look like the following:
```
platform :osx, '10.12.2'
```
+21
View File
@@ -0,0 +1,21 @@
# Local audio_service 0.18.19 patch
Source: the published `audio_service` 0.18.19 package (MIT; see LICENSE).
Only runtime sources are vendored. This replaces the same dependency, without
adding another playback engine or native SDK.
- Android: include custom media actions in MediaStyle notifications on Android
12 and older. Upstream exposes them only through PlaybackState, which hides
them on those versions. Keep custom action order and compact indices aligned.
- Android: allow a custom action to supply an explicit in-app activity class
using the `spotiflac.activity` extra. Its notification PendingIntent opens the
output picker directly. These activity controls are excluded from
PlaybackState custom actions: background launches from media-session binder
callbacks are blocked on newer Android. Use its native output switcher.
This is only for app-owned controls, not extension input.
Android 13+ still chooses the positions and built-in transport icons in System
UI. We preserve real play/pause/previous/next semantics for headsets, lock screen
and Android Auto instead of disguising them as unrelated custom actions.
When upgrading, compare these changes against the matching upstream files.
+73
View File
@@ -0,0 +1,73 @@
group = "com.ryanheise.audioservice"
version = "1.0-SNAPSHOT"
val args = listOf("-Xlint:deprecation", "-Xlint:unchecked")
buildscript {
// Uncomment when moving to Kotlin
// val kotlinVersion = "2.3.20"
val agpVersion = "9.0.1"
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:$agpVersion")
// Uncomment when moving to Kotlin
// classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
plugins {
id("com.android.library")
}
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.addAll(args)
}
// Uncomment when moving to Kotlin
// val agpMajor = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.substringBefore('.').toInt()
// if (agpMajor < 9) {
// apply(plugin = "org.jetbrains.kotlin.android")
// }
android {
namespace = "com.ryanheise.audioservice"
compileSdk = 35
defaultConfig {
minSdk = 19
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
// Uncomment when moving to Kotlin
// sourceSets {
// getByName("main") {
// java.srcDirs("src/main/kotlin")
// }
// getByName("test") {
// java.srcDirs("src/test/kotlin")
// }
// }
lint {
disable += listOf("InvalidPackage")
}
}
dependencies {
implementation("androidx.media:media:1.7.0")
implementation("androidx.core:core:1.13.1")
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx1536M
android.enableR8=true
android.useAndroidX=true
android.enableJetifier=true
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+1
View File
@@ -0,0 +1 @@
rootProject.name = "audio_service"
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,10 @@
package com.ryanheise.audioservice;
public enum AudioProcessingState {
idle,
loading,
buffering,
ready,
completed,
error,
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,15 @@
package com.ryanheise.audioservice;
import android.content.Context;
import androidx.annotation.NonNull;
import io.flutter.embedding.android.FlutterActivity;
import io.flutter.embedding.engine.FlutterEngine;
public class AudioServiceActivity extends FlutterActivity {
@Override
public FlutterEngine provideFlutterEngine(@NonNull Context context) {
return AudioServicePlugin.getFlutterEngine(context);
}
}
@@ -0,0 +1,120 @@
package com.ryanheise.audioservice;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import java.util.Iterator;
import java.util.Map;
import org.json.JSONObject;
public class AudioServiceConfig {
private static final String SHARED_PREFERENCES_NAME = "audio_service_preferences";
private static final String KEY_ANDROID_RESUME_ON_CLICK = "androidResumeOnClick";
private static final String KEY_ANDROID_NOTIFICATION_CHANNEL_ID = "androidNotificationChannelId";
private static final String KEY_ANDROID_NOTIFICATION_CHANNEL_NAME = "androidNotificationChannelName";
private static final String KEY_ANDROID_NOTIFICATION_CHANNEL_DESCRIPTION = "androidNotificationChannelDescription";
private static final String KEY_NOTIFICATION_COLOR = "notificationColor";
private static final String KEY_ANDROID_NOTIFICATION_ICON = "androidNotificationIcon";
private static final String KEY_ANDROID_SHOW_NOTIFICATION_BADGE = "androidShowNotificationBadge";
private static final String KEY_ANDROID_NOTIFICATION_CLICK_STARTS_ACTIVITY = "androidNotificationClickStartsActivity";
private static final String KEY_ANDROID_NOTIFICATION_ONGOING = "androidNotificationOngoing";
private static final String KEY_ANDROID_STOP_FOREGROUND_ON_PAUSE = "androidStopForegroundOnPause";
private static final String KEY_ART_DOWNSCALE_WIDTH = "artDownscaleWidth";
private static final String KEY_ART_DOWNSCALE_HEIGHT = "artDownscaleHeight";
private static final String KEY_ACTIVITY_CLASS_NAME = "activityClassName";
private static final String KEY_BROWSABLE_ROOT_EXTRAS = "androidBrowsableRootExtras";
private SharedPreferences preferences;
public boolean androidResumeOnClick;
public String androidNotificationChannelId;
public String androidNotificationChannelName;
public String androidNotificationChannelDescription;
public int notificationColor;
public String androidNotificationIcon;
public boolean androidShowNotificationBadge;
public boolean androidNotificationClickStartsActivity;
public boolean androidNotificationOngoing;
public boolean androidStopForegroundOnPause;
public int artDownscaleWidth;
public int artDownscaleHeight;
public String activityClassName;
public String browsableRootExtras;
public AudioServiceConfig(Context context) {
preferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE);
androidResumeOnClick = preferences.getBoolean(KEY_ANDROID_RESUME_ON_CLICK, true);
androidNotificationChannelId = preferences.getString(KEY_ANDROID_NOTIFICATION_CHANNEL_ID, null);
androidNotificationChannelName = preferences.getString(KEY_ANDROID_NOTIFICATION_CHANNEL_NAME, null);
androidNotificationChannelDescription = preferences.getString(KEY_ANDROID_NOTIFICATION_CHANNEL_DESCRIPTION, null);
notificationColor = preferences.getInt(KEY_NOTIFICATION_COLOR, -1);
androidNotificationIcon = preferences.getString(KEY_ANDROID_NOTIFICATION_ICON, "mipmap/ic_launcher");
androidShowNotificationBadge = preferences.getBoolean(KEY_ANDROID_SHOW_NOTIFICATION_BADGE, false);
androidNotificationClickStartsActivity = preferences.getBoolean(KEY_ANDROID_NOTIFICATION_CLICK_STARTS_ACTIVITY, true);
androidNotificationOngoing = preferences.getBoolean(KEY_ANDROID_NOTIFICATION_ONGOING, false);
androidStopForegroundOnPause = preferences.getBoolean(KEY_ANDROID_STOP_FOREGROUND_ON_PAUSE, true);
artDownscaleWidth = preferences.getInt(KEY_ART_DOWNSCALE_WIDTH, -1);
artDownscaleHeight = preferences.getInt(KEY_ART_DOWNSCALE_HEIGHT, -1);
activityClassName = preferences.getString(KEY_ACTIVITY_CLASS_NAME, null);
browsableRootExtras = preferences.getString(KEY_BROWSABLE_ROOT_EXTRAS, null);
}
public void setBrowsableRootExtras(Map<?,?> map) {
if (map != null) {
JSONObject json = new JSONObject(map);
browsableRootExtras = json.toString();
} else {
browsableRootExtras = null;
}
}
public Bundle getBrowsableRootExtras() {
if (browsableRootExtras == null) return null;
try {
JSONObject json = new JSONObject(browsableRootExtras);
Bundle extras = new Bundle();
for (Iterator<String> it = json.keys(); it.hasNext();) {
String key = it.next();
try {
extras.putInt(key, json.getInt(key));
} catch (Exception e1) {
try {
extras.putBoolean(key, json.getBoolean(key));
} catch (Exception e2) {
try {
extras.putDouble(key, json.getDouble(key));
} catch (Exception e3) {
try {
extras.putString(key, json.getString(key));
} catch (Exception e4) {
System.out.println("Unsupported extras value for key " + key);
}
}
}
}
}
return extras;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
public void save() {
preferences.edit()
.putBoolean(KEY_ANDROID_RESUME_ON_CLICK, androidResumeOnClick)
.putString(KEY_ANDROID_NOTIFICATION_CHANNEL_ID, androidNotificationChannelId)
.putString(KEY_ANDROID_NOTIFICATION_CHANNEL_NAME, androidNotificationChannelName)
.putString(KEY_ANDROID_NOTIFICATION_CHANNEL_DESCRIPTION, androidNotificationChannelDescription)
.putInt(KEY_NOTIFICATION_COLOR, notificationColor)
.putString(KEY_ANDROID_NOTIFICATION_ICON, androidNotificationIcon)
.putBoolean(KEY_ANDROID_SHOW_NOTIFICATION_BADGE, androidShowNotificationBadge)
.putBoolean(KEY_ANDROID_NOTIFICATION_CLICK_STARTS_ACTIVITY, androidNotificationClickStartsActivity)
.putBoolean(KEY_ANDROID_NOTIFICATION_ONGOING, androidNotificationOngoing)
.putBoolean(KEY_ANDROID_STOP_FOREGROUND_ON_PAUSE, androidStopForegroundOnPause)
.putInt(KEY_ART_DOWNSCALE_WIDTH, artDownscaleWidth)
.putInt(KEY_ART_DOWNSCALE_HEIGHT, artDownscaleHeight)
.putString(KEY_ACTIVITY_CLASS_NAME, activityClassName)
.putString(KEY_BROWSABLE_ROOT_EXTRAS, browsableRootExtras)
.apply();
}
}
@@ -0,0 +1,48 @@
package com.ryanheise.audioservice;
import android.content.Context;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import io.flutter.embedding.android.FlutterFragmentActivity;
import io.flutter.embedding.engine.FlutterEngine;
public class AudioServiceFragmentActivity extends FlutterFragmentActivity {
@Override
public FlutterEngine provideFlutterEngine(@NonNull Context context) {
return AudioServicePlugin.getFlutterEngine(context);
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
AudioServicePlugin.getFlutterEngine(this);
super.onCreate(savedInstanceState);
}
@Override
protected String getCachedEngineId() {
AudioServicePlugin.getFlutterEngine(this);
return AudioServicePlugin.getFlutterEngineId();
}
// The engine is created and managed by AudioServicePlugin,
// it should not be destroyed with the activity.
@Override
public boolean shouldDestroyEngineWithHost() {
return false;
}
// Make this protected method public
@Override
public String getInitialRoute() {
return super.getInitialRoute();
}
// Make this protected method public
@Override
public boolean shouldHandleDeeplinking() {
return super.shouldHandleDeeplinking();
}
}
@@ -0,0 +1,27 @@
package com.ryanheise.audioservice;
import java.util.Map;
import java.util.Objects;
public class CustomMediaAction {
public final String name;
public final Map<?, ?> extras;
public CustomMediaAction(String name, Map<?, ?> extras) {
this.name = name;
this.extras = extras;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CustomMediaAction that = (CustomMediaAction) o;
return name.equals(that.name) && Objects.equals(extras, that.extras);
}
@Override
public int hashCode() {
return Objects.hash(name, extras);
}
}
@@ -0,0 +1,7 @@
package com.ryanheise.audioservice;
public enum MediaButton {
media,
next,
previous
}
@@ -0,0 +1,25 @@
package com.ryanheise.audioservice;
import android.content.Context;
import android.content.Intent;
public class MediaButtonReceiver extends androidx.media.session.MediaButtonReceiver {
public static final String ACTION_CUSTOM = "com.ryanheise.audioservice.intent.action.CUSTOM";
public static final String ACTION_NOTIFICATION_DELETE = "com.ryanheise.audioservice.intent.action.ACTION_NOTIFICATION_DELETE";
@Override
public void onReceive(Context context, Intent intent) {
if (intent != null && ACTION_CUSTOM.equals(intent.getAction()) && AudioService.instance != null) {
AudioService.instance.handleCustomNotificationAction(
intent.getStringExtra("action"), intent.getBundleExtra("extras"));
return;
}
if (intent != null
&& ACTION_NOTIFICATION_DELETE.equals(intent.getAction())
&& AudioService.instance != null) {
AudioService.instance.handleDeleteNotification();
return;
}
super.onReceive(context, intent);
}
}
@@ -0,0 +1,27 @@
package com.ryanheise.audioservice;
import java.util.Objects;
public class MediaControl {
public final String icon;
public final String label;
public final long actionCode;
public final CustomMediaAction customAction;
public MediaControl(String icon, String label, long actionCode, CustomMediaAction customAction) {
this.icon = icon;
this.label = label;
this.actionCode = actionCode;
this.customAction = customAction;
}
@Override
public boolean equals(Object other) {
if (other instanceof MediaControl) {
MediaControl otherControl = (MediaControl)other;
return icon.equals(otherControl.icon) && label.equals(otherControl.label) && actionCode == otherControl.actionCode && Objects.equals(customAction, otherControl.customAction);
} else {
return false;
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 561 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 857 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 B

+23
View File
@@ -0,0 +1,23 @@
#
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html
#
Pod::Spec.new do |s|
s.name = 'audio_service'
s.version = '0.0.1'
s.summary = 'Flutter plugin to play audio in the background while the screen is off.'
s.description = <<-DESC
Flutter plugin to play audio in the background while the screen is off.
DESC
s.homepage = 'https://github.com/ryanheise/audio_service'
s.license = { :file => '../LICENSE' }
s.author = { 'Ryan Heise' => 'ryan@ryanheise.com' }
s.source = { :path => '.' }
s.source_files = 'audio_service/Sources/audio_service/**/*.{h,m}'
s.public_header_files = 'audio_service/Sources/audio_service/include/**/*.h'
s.ios.dependency 'Flutter'
s.osx.dependency 'FlutterMacOS'
s.ios.deployment_target = '12.0'
s.osx.deployment_target = '10.14'
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
end
@@ -0,0 +1,25 @@
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "audio_service",
platforms: [
.iOS("12.0"),
.macOS("10.14")
],
products: [
.library(name: "audio-service", targets: ["audio_service"])
],
dependencies: [],
targets: [
.target(
name: "audio_service",
dependencies: [],
cSettings: [
.headerSearchPath("include/audio_service")
]
)
]
)
@@ -0,0 +1,577 @@
#import "./include/audio_service/AudioServicePlugin.h"
#import <AVFoundation/AVFoundation.h>
#import <MediaPlayer/MediaPlayer.h>
// If you'd like to help, please see the TODO comments below, then open a
// GitHub issue to announce your intention to work on a particular feature, and
// submit a pull request. We have an open discussion over at issue #10 about
// all things iOS if you'd like to discuss approaches or ask for input. Thank
// you for your support!
static NSHashTable<AudioServicePlugin *> *plugins = nil;
static FlutterMethodChannel *handlerChannel = nil;
static FlutterResult startResult = nil;
static MPRemoteCommandCenter *commandCenter = nil;
static NSMutableDictionary *mediaItem = nil;
static long actionBits = 0;
static NSMutableArray *commands;
static BOOL _controlsUpdated = NO;
static enum AudioProcessingState processingState = ApsIdle;
static BOOL playing = NO;
static NSNumber *position = nil;
static NSNumber *bufferedPosition = nil;
static NSNumber *updateTime = nil;
static NSNumber *speed = nil;
static NSNumber *repeatMode = nil;
static NSNumber *shuffleMode = nil;
static NSNumber *fastForwardInterval = nil;
static NSNumber *rewindInterval = nil;
static MPMediaItemArtwork* artwork = nil;
static NSMutableDictionary *nowPlayingInfo = nil;
@implementation AudioServicePlugin {
FlutterMethodChannel *_channel;
}
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
@synchronized(self) {
if (!plugins) {
plugins = [NSHashTable weakObjectsHashTable];
}
AudioServicePlugin *instance = [[AudioServicePlugin alloc] initWithRegistrar:registrar];
[registrar addMethodCallDelegate:instance channel:instance.channel];
[plugins addObject:instance];
if (!handlerChannel) {
processingState = ApsIdle;
position = @(0);
bufferedPosition = @(0);
long long msSinceEpoch = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0);
updateTime = [NSNumber numberWithLongLong: msSinceEpoch];
speed = [NSNumber numberWithDouble: 1.0];
repeatMode = @(0);
shuffleMode = @(0);
nowPlayingInfo = [NSMutableDictionary new];
handlerChannel = [FlutterMethodChannel
methodChannelWithName:@"com.ryanheise.audio_service.handler.methods"
binaryMessenger:[registrar messenger]];
[registrar addMethodCallDelegate:instance channel:handlerChannel];
}
}
}
- (instancetype)initWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar {
self = [super init];
NSAssert(self, @"super init cannot be nil");
_channel = [FlutterMethodChannel
methodChannelWithName:@"com.ryanheise.audio_service.client.methods"
binaryMessenger:[registrar messenger]];
return self;
}
- (FlutterMethodChannel *)channel {
return _channel;
}
- (void)invokeClientMethod:(NSString *)method arguments:(id _Nullable)arguments {
for (AudioServicePlugin *plugin in plugins) {
[plugin.channel invokeMethod:method arguments:arguments];
}
}
- (void)invokeClientMethod:(NSString *)method arguments:(id _Nullable)arguments result:(FlutterResult)result {
for (AudioServicePlugin *plugin in plugins) {
[plugin.channel invokeMethod:method arguments:arguments result:result];
}
}
- (void)activateCommandCenter {
//NSLog(@"### activateCommandCenter");
commandCenter = [MPRemoteCommandCenter sharedCommandCenter];
commands = [NSMutableArray new];
[commands addObjectsFromArray:@[
commandCenter.stopCommand,
commandCenter.pauseCommand,
commandCenter.playCommand,
commandCenter.skipBackwardCommand,
commandCenter.previousTrackCommand,
commandCenter.nextTrackCommand,
commandCenter.skipForwardCommand,
[NSNull null],
[NSNull null], // changePlaybackPositionCommand, put below
commandCenter.togglePlayPauseCommand,
[NSNull null],
[NSNull null],
[NSNull null],
[NSNull null],
[NSNull null],
[NSNull null],
[NSNull null],
[NSNull null],
commandCenter.changeRepeatModeCommand,
[NSNull null],
[NSNull null],
commandCenter.changeShuffleModeCommand,
commandCenter.seekBackwardCommand,
commandCenter.seekForwardCommand,
commandCenter.changePlaybackRateCommand,
]];
if (@available(iOS 9.1, macOS 10.12.2, *)) {
commands[8] = commandCenter.changePlaybackPositionCommand;
}
[commandCenter.changePlaybackRateCommand setEnabled:YES];
[commandCenter.togglePlayPauseCommand setEnabled:YES];
[commandCenter.togglePlayPauseCommand addTarget:self action:@selector(togglePlayPause:)];
// TODO: enable more commands
// Language options
if (@available(iOS 9.0, macOS 10.12.2, *)) {
[commandCenter.enableLanguageOptionCommand setEnabled:NO];
[commandCenter.disableLanguageOptionCommand setEnabled:NO];
}
// Rating
[commandCenter.ratingCommand setEnabled:NO];
// Feedback
[commandCenter.likeCommand setEnabled:NO];
[commandCenter.dislikeCommand setEnabled:NO];
[commandCenter.bookmarkCommand setEnabled:NO];
}
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
if ([@"configure" isEqualToString:call.method]) {
NSDictionary *args = (NSDictionary *)call.arguments;
NSDictionary *configMap = (NSDictionary *)args[@"config"];
fastForwardInterval = configMap[@"fastForwardInterval"];
rewindInterval = configMap[@"rewindInterval"];
result(@{});
} else if ([@"setState" isEqualToString:call.method]) {
NSDictionary *args = (NSDictionary *)call.arguments;
NSDictionary *stateMap = (NSDictionary *)args[@"state"];
long long msSinceEpoch;
if (stateMap[@"updateTime"] != [NSNull null]) {
msSinceEpoch = [stateMap[@"updateTime"] longLongValue];
} else {
msSinceEpoch = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0);
}
actionBits = 0;
NSArray *controlsArray = stateMap[@"controls"];
for (int i = 0; i < controlsArray.count; i++) {
NSDictionary *control = (NSDictionary *)controlsArray[i];
NSNumber *actionIndex = (NSNumber *)control[@"action"];
int actionCode = 1 << [actionIndex intValue];
actionBits |= actionCode;
}
NSArray *systemActionsArray = stateMap[@"systemActions"];
for (int i = 0; i < systemActionsArray.count; i++) {
NSNumber *actionIndex = (NSNumber *)systemActionsArray[i];
int actionCode = 1 << [actionIndex intValue];
actionBits |= actionCode;
}
processingState = [stateMap[@"processingState"] intValue];
BOOL oldPlaying = playing;
NSNumber *oldSpeed = speed;
NSNumber *oldPosition = position;
playing = [stateMap[@"playing"] boolValue];
position = stateMap[@"updatePosition"];
bufferedPosition = stateMap[@"bufferedPosition"];
speed = stateMap[@"speed"];
repeatMode = stateMap[@"repeatMode"];
shuffleMode = stateMap[@"shuffleMode"];
updateTime = [NSNumber numberWithLongLong: msSinceEpoch];
if (playing && !commandCenter) {
#if TARGET_OS_IPHONE
[AVAudioSession sharedInstance];
#endif
[self activateCommandCenter];
}
[self updateControls];
if (playing != oldPlaying ||
speed.doubleValue != oldSpeed.doubleValue ||
position.longLongValue != oldPosition.longLongValue) {
[self updateNowPlayingInfo];
}
result(@{});
} else if ([@"setQueue" isEqualToString:call.method]) {
result(@{});
} else if ([@"setMediaItem" isEqualToString:call.method]) {
NSDictionary *args = (NSDictionary *)call.arguments;
mediaItem = args[@"mediaItem"];
NSString* artUri = mediaItem[@"artUri"];
artwork = nil;
if (![artUri isEqual: [NSNull null]]) {
NSString* artCacheFilePath = (NSString *)[NSNull null];
NSDictionary* extras = mediaItem[@"extras"];
if (![extras isEqual: [NSNull null]]) {
artCacheFilePath = extras[@"artCacheFile"];
}
if (![artCacheFilePath isEqual: [NSNull null]]) {
#if TARGET_OS_IPHONE
UIImage* artImage = [UIImage imageWithContentsOfFile:artCacheFilePath];
#else
NSImage* artImage = [[NSImage alloc] initWithContentsOfFile:artCacheFilePath];
#endif
if (artImage != nil) {
#if TARGET_OS_IPHONE
artwork = [[MPMediaItemArtwork alloc] initWithImage: artImage];
#else
artwork = [[MPMediaItemArtwork alloc] initWithBoundsSize:artImage.size requestHandler:^NSImage* _Nonnull(CGSize aSize) {
return artImage;
}];
#endif
}
}
}
[self updateNowPlayingInfo];
result(@{});
} else if ([@"setPlaybackInfo" isEqualToString:call.method]) {
result(@{});
} else if ([@"notifyChildrenChanged" isEqualToString:call.method]) {
result(@{});
} else if ([@"androidForceEnableMediaButtons" isEqualToString:call.method]) {
result(@{});
} else if ([@"stopService" isEqualToString:call.method]) {
[commandCenter.changePlaybackRateCommand setEnabled:NO];
[commandCenter.togglePlayPauseCommand setEnabled:NO];
[commandCenter.togglePlayPauseCommand removeTarget:nil];
[MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = nil;
processingState = ApsIdle;
actionBits = 0;
[self updateControls];
_controlsUpdated = NO;
startResult = nil;
commandCenter = nil;
result(@{});
}
}
- (MPRemoteCommandHandlerStatus) play: (MPRemoteCommandEvent *) event {
//NSLog(@"play");
[handlerChannel invokeMethod:@"play" arguments:@{}];
return MPRemoteCommandHandlerStatusSuccess;
}
- (MPRemoteCommandHandlerStatus) pause: (MPRemoteCommandEvent *) event {
//NSLog(@"pause");
[handlerChannel invokeMethod:@"pause" arguments:@{}];
return MPRemoteCommandHandlerStatusSuccess;
}
- (BOOL)updateNowPlayingField:(NSString *)field value:(id)value {
if (![value isEqual:nowPlayingInfo[field]]) {
if (value != nil && value != [NSNull null]) {
//NSLog(@"### %@ = '%@'", field, value);
nowPlayingInfo[field] = value;
} else {
//NSLog(@"### %@ = nil", field);
[nowPlayingInfo removeObjectForKey:field];
}
return YES;
}
return NO;
}
- (void) updateNowPlayingInfo {
BOOL updated = NO;
if (mediaItem) {
updated |= [self updateNowPlayingField:MPMediaItemPropertyTitle value:mediaItem[@"title"]];
updated |= [self updateNowPlayingField:MPMediaItemPropertyAlbumTitle value:mediaItem[@"album"]];
updated |= [self updateNowPlayingField:MPMediaItemPropertyArtist value:mediaItem[@"artist"]];
NSNumber *duration = mediaItem[@"duration"];
if (duration == (id)[NSNull null]) duration = @(0);
updated |= [self updateNowPlayingField:MPMediaItemPropertyPlaybackDuration value:([NSNumber numberWithDouble: ([duration doubleValue] / 1000)])];
if (@available(iOS 3.0, macOS 10.13.2, *)) {
updated |= [self updateNowPlayingField:MPMediaItemPropertyArtwork value:artwork];
}
}
if (@available(iOS 10.0, macOS 10.12.2, *)) {
updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyMediaType value:@(MPNowPlayingInfoMediaTypeAudio)];
}
updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyPlaybackRate value:(playing ? speed : [NSNumber numberWithDouble: 0.0])];
updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyDefaultPlaybackRate value:(playing ? speed : [NSNumber numberWithDouble: 0.0])];
updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyElapsedPlaybackTime value:[NSNumber numberWithDouble:([position doubleValue] / 1000)]];
MPNowPlayingInfoCenter *center = [MPNowPlayingInfoCenter defaultCenter];
#if TARGET_OS_OSX
if (@available(iOS 13.0, macOS 10.12.2, *)) {
center.playbackState = playing ? MPNowPlayingPlaybackStatePlaying : MPNowPlayingPlaybackStatePaused;
}
#endif
if (@available(iOS 10.0, macOS 10.12.2, *)) {
updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyIsLiveStream value:mediaItem[@"isLive"]];
}
if (updated) {
//NSLog(@"### updating nowPlayingInfo");
center.nowPlayingInfo = nowPlayingInfo;
}
// TODO: List of all unused "nowPlayingInfo" keys, we might want to use these at some point:
//
// * MPNowPlayingInfoCollectionIdentifier
// * MPNowPlayingInfoPropertyAvailableLanguageOptions
// * MPNowPlayingInfoPropertyAssetURL
// * MPNowPlayingInfoPropertyChapterCount
// * MPNowPlayingInfoPropertyChapterNumber
// * MPNowPlayingInfoPropertyCurrentLanguageOptions
// * MPNowPlayingInfoPropertyDefaultPlaybackRate
// * MPNowPlayingInfoPropertyCurrentPlaybackDate
// * MPNowPlayingInfoPropertyExternalContentIdentifier
// * MPNowPlayingInfoPropertyExternalUserProfileIdentifier
// * MPNowPlayingInfoPropertyPlaybackProgress
// * MPNowPlayingInfoPropertyPlaybackQueueCount
// * MPNowPlayingInfoPropertyPlaybackQueueIndex
// * MPNowPlayingInfoPropertyServiceIdentifier
}
- (void) updateControls {
if (!commandCenter) return;
for (enum MediaAction action = AStop; action <= ASetSpeed; action++) {
[self updateControl:action];
}
_controlsUpdated = YES;
}
- (void) updateControl:(enum MediaAction)action {
MPRemoteCommand *command = commands[action];
if (command == (id)[NSNull null]) return;
// Shift the actionBits right until the least significant bit is the tested action bit, and AND that with a 1 at the same position.
// All bytes become 0, other than the tested action bit, which will be 0 or 1 according to its status in the actionBits long.
BOOL enable = ((actionBits >> action) & 1);
if (_controlsUpdated && enable == command.enabled) return;
//NSLog(@"## updateControl %@ enable=%@", @(action), @(enable));
[command setEnabled:enable];
switch (action) {
case AStop:
if (enable) {
[commandCenter.stopCommand addTarget:self action:@selector(stop:)];
} else {
[commandCenter.stopCommand removeTarget:nil];
}
break;
case APause:
if (enable) {
[commandCenter.pauseCommand addTarget:self action:@selector(pause:)];
} else {
[commandCenter.pauseCommand removeTarget:nil];
}
break;
case APlay:
if (enable) {
[commandCenter.playCommand addTarget:self action:@selector(play:)];
} else {
[commandCenter.playCommand removeTarget:nil];
}
break;
case ARewind:
if (rewindInterval.integerValue > 0) {
if (enable) {
[commandCenter.skipBackwardCommand addTarget: self action:@selector(skipBackward:)];
int rewindIntervalInSeconds = [rewindInterval intValue]/1000;
NSNumber *rewindIntervalInSec = [NSNumber numberWithInt: rewindIntervalInSeconds];
commandCenter.skipBackwardCommand.preferredIntervals = @[rewindIntervalInSec];
} else {
[commandCenter.skipBackwardCommand removeTarget:nil];
}
}
break;
case ASkipToPrevious:
if (enable) {
[commandCenter.previousTrackCommand addTarget:self action:@selector(previousTrack:)];
} else {
[commandCenter.previousTrackCommand removeTarget:nil];
}
break;
case ASkipToNext:
if (enable) {
[commandCenter.nextTrackCommand addTarget:self action:@selector(nextTrack:)];
} else {
[commandCenter.nextTrackCommand removeTarget:nil];
}
break;
case AFastForward:
if (fastForwardInterval.integerValue > 0) {
if (enable) {
[commandCenter.skipForwardCommand addTarget: self action:@selector(skipForward:)];
int fastForwardIntervalInSeconds = [fastForwardInterval intValue]/1000;
NSNumber *fastForwardIntervalInSec = [NSNumber numberWithInt: fastForwardIntervalInSeconds];
commandCenter.skipForwardCommand.preferredIntervals = @[fastForwardIntervalInSec];
} else {
[commandCenter.skipForwardCommand removeTarget:nil];
}
}
break;
case ASetRating:
// TODO:
// commandCenter.ratingCommand
// commandCenter.dislikeCommand
// commandCenter.bookmarkCommand
break;
case ASeekTo:
if (@available(iOS 9.1, macOS 10.12.2, *)) {
if (enable) {
[commandCenter.changePlaybackPositionCommand addTarget:self action:@selector(changePlaybackPosition:)];
} else {
[commandCenter.changePlaybackPositionCommand removeTarget:nil];
}
}
break;
case APlayPause:
// Automatically enabled.
break;
case ASetRepeatMode:
if (enable) {
[commandCenter.changeRepeatModeCommand addTarget:self action:@selector(changeRepeatMode:)];
} else {
[commandCenter.changeRepeatModeCommand removeTarget:nil];
}
break;
case ASetShuffleMode:
if (enable) {
[commandCenter.changeShuffleModeCommand addTarget:self action:@selector(changeShuffleMode:)];
} else {
[commandCenter.changeShuffleModeCommand removeTarget:nil];
}
break;
case ASeekBackward:
if (enable) {
[commandCenter.seekBackwardCommand addTarget:self action:@selector(seekBackward:)];
} else {
[commandCenter.seekBackwardCommand removeTarget:nil];
}
break;
case ASeekForward:
if (enable) {
[commandCenter.seekForwardCommand addTarget:self action:@selector(seekForward:)];
} else {
[commandCenter.seekForwardCommand removeTarget:nil];
}
break;
case ASetSpeed:
if (enable) {
// [commandCenter.changePlaybackRateCommand setSupportedPlaybackRates:@[@(1),@(1.5),@(2)]]
[commandCenter.changePlaybackRateCommand addTarget:self action:@selector(changePlaybackRate:)];
} else {
[commandCenter.changePlaybackRateCommand removeTarget:nil];
}
break;
default:
break;
}
}
- (MPRemoteCommandHandlerStatus) togglePlayPause: (MPRemoteCommandEvent *) event {
//NSLog(@"togglePlayPause");
[handlerChannel invokeMethod:@"click" arguments:@{
@"button":@(0)
}];
return MPRemoteCommandHandlerStatusSuccess;
}
- (MPRemoteCommandHandlerStatus) stop: (MPRemoteCommandEvent *) event {
//NSLog(@"stop");
[handlerChannel invokeMethod:@"stop" arguments:@{}];
return MPRemoteCommandHandlerStatusSuccess;
}
- (MPRemoteCommandHandlerStatus) nextTrack: (MPRemoteCommandEvent *) event {
//NSLog(@"nextTrack");
[handlerChannel invokeMethod:@"skipToNext" arguments:@{}];
return MPRemoteCommandHandlerStatusSuccess;
}
- (MPRemoteCommandHandlerStatus) previousTrack: (MPRemoteCommandEvent *) event {
//NSLog(@"previousTrack");
[handlerChannel invokeMethod:@"skipToPrevious" arguments:@{}];
return MPRemoteCommandHandlerStatusSuccess;
}
- (MPRemoteCommandHandlerStatus) changePlaybackPosition: (MPChangePlaybackPositionCommandEvent *) event {
//NSLog(@"changePlaybackPosition");
[handlerChannel invokeMethod:@"seek" arguments: @{
@"position":@((long long) (event.positionTime * 1000000.0))
}];
return MPRemoteCommandHandlerStatusSuccess;
}
- (MPRemoteCommandHandlerStatus) skipForward: (MPRemoteCommandEvent *) event {
//NSLog(@"skipForward");
[handlerChannel invokeMethod:@"fastForward" arguments:@{}];
return MPRemoteCommandHandlerStatusSuccess;
}
- (MPRemoteCommandHandlerStatus) skipBackward: (MPRemoteCommandEvent *) event {
//NSLog(@"skipBackward");
[handlerChannel invokeMethod:@"rewind" arguments:@{}];
return MPRemoteCommandHandlerStatusSuccess;
}
- (MPRemoteCommandHandlerStatus) seekForward: (MPSeekCommandEvent *) event {
//NSLog(@"seekForward");
BOOL begin = event.type == MPSeekCommandEventTypeBeginSeeking;
[handlerChannel invokeMethod:@"seekForward" arguments:@{
@"begin":@(begin)
}];
return MPRemoteCommandHandlerStatusSuccess;
}
- (MPRemoteCommandHandlerStatus) seekBackward: (MPSeekCommandEvent *) event {
//NSLog(@"seekBackward");
BOOL begin = event.type == MPSeekCommandEventTypeBeginSeeking;
[handlerChannel invokeMethod:@"seekBackward" arguments:@{
@"begin":@(begin)
}];
return MPRemoteCommandHandlerStatusSuccess;
}
- (MPRemoteCommandHandlerStatus) changeRepeatMode: (MPChangeRepeatModeCommandEvent *) event {
//NSLog(@"changeRepeatMode");
int modeIndex;
switch (event.repeatType) {
case MPRepeatTypeOff:
modeIndex = 0;
break;
case MPRepeatTypeOne:
modeIndex = 1;
break;
// MPRepeatTypeAll
default:
modeIndex = 2;
break;
}
[handlerChannel invokeMethod:@"setRepeatMode" arguments:@{
@"repeatMode":@(modeIndex)
}];
return MPRemoteCommandHandlerStatusSuccess;
}
- (MPRemoteCommandHandlerStatus) changePlaybackRate: (MPChangePlaybackRateCommandEvent *) event {
//NSLog(@"changePlaybackRate");
[handlerChannel invokeMethod:@"setSpeed" arguments:@{
@"speed":@(event.playbackRate)
}];
return MPRemoteCommandHandlerStatusSuccess;
}
- (MPRemoteCommandHandlerStatus) changeShuffleMode: (MPChangeShuffleModeCommandEvent *) event {
//NSLog(@"changeShuffleMode");
int modeIndex;
switch (event.shuffleType) {
case MPShuffleTypeOff:
modeIndex = 0;
break;
case MPShuffleTypeItems:
modeIndex = 1;
break;
// MPShuffleTypeCollections
default:
modeIndex = 2;
break;
}
[handlerChannel invokeMethod:@"setShuffleMode" arguments:@{
@"shuffleMode":@(modeIndex)
}];
return MPRemoteCommandHandlerStatusSuccess;
}
- (void) dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
@end
@@ -0,0 +1,50 @@
#import <AVFoundation/AVFoundation.h>
#if TARGET_OS_OSX
#import <FlutterMacOS/FlutterMacOS.h>
#else
#import <Flutter/Flutter.h>
#endif
@interface AudioServicePlugin : NSObject<FlutterPlugin>
@property (readonly, nonatomic) FlutterMethodChannel *channel;
@end
enum AudioProcessingState {
ApsIdle,
ApsLoading,
ApsBuffering,
ApsReady,
ApsCompleted,
ApsError
};
enum MediaAction {
AStop,
APause,
APlay,
ARewind,
ASkipToPrevious,
ASkipToNext,
AFastForward,
ASetRating,
ASeekTo,
APlayPause,
APlayFromMediaId,
APlayFromSearch,
ASkipToQueueItem,
APlayFromUri,
APrepare,
APrepareFromMediaId,
APrepareFromSearch,
APrepareFromUri,
ASetRepeatMode,
ASetShuffleModeEnabled, // deprecated
ASetCaptioningEnabled,
ASetShuffleMode,
// iOS/macOS-specific
ASeekBackward,
ASeekForward,
ASetSpeed,
};
File diff suppressed because it is too large. Load diff
+67
View File
@@ -0,0 +1,67 @@
name: audio_service
description: Flutter plugin to play audio in the background while the screen is off.
version: 0.18.19
repository: https://github.com/ryanheise/audio_service/tree/minor/audio_service
issue_tracker: https://github.com/ryanheise/audio_service/issues
topics:
- audio
- background
environment:
sdk: ^3.6.0
flutter: ">=3.27.0"
dependencies:
# Use these deps for local development
# audio_service_platform_interface:
# path: ../audio_service_platform_interface
# audio_service_web:
# path: ../audio_service_web
# Use these deps when pushing to origin (for the benefit of testers)
# audio_service_platform_interface:
# git:
# url: https://github.com/ryanheise/audio_service.git
# ref: one-isolate
# path: audio_service_platform_interface
# audio_service_web:
# git:
# url: https://github.com/ryanheise/audio_service.git
# ref: one-isolate
# path: audio_service_web
# Use these deps when publishing.
audio_service_platform_interface: ^0.1.3
audio_service_web: ^0.1.4
audio_session: '>=0.1.25 <0.3.0'
rxdart: '>=0.26.0 <0.29.0'
flutter_cache_manager: ^3.3.1
clock: ^1.1.0
js: '>=0.6.3 <0.8.0'
flutter:
sdk: flutter
flutter_web_plugins:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
mockito: ^5.0.0
fake_async: ^1.2.0
flutter_lints: ^3.0.1
flutter:
plugin:
platforms:
android:
package: com.ryanheise.audioservice
pluginClass: AudioServicePlugin
ios:
pluginClass: AudioServicePlugin
sharedDarwinSource: true
macos:
pluginClass: AudioServicePlugin
sharedDarwinSource: true
web:
default_package: audio_service_web