From 73fce9905f9ffeec0270f7c89b70cd0eaa762fb6 Mon Sep 17 00:00:00 2001 From: exttex Date: Fri, 18 Sep 2020 19:25:00 +0200 Subject: [PATCH] Initial commit --- .github/FUNDING.yml | 1 + .github/ISSUE_TEMPLATE/bug_report.md | 48 + .github/ISSUE_TEMPLATE/config.yml | 8 + .../ISSUE_TEMPLATE/documentation-request.md | 39 + .github/ISSUE_TEMPLATE/feature_request.md | 38 + .../frequently-asked-questions.md | 19 + .github/workflows/auto-close.yml | 12 + .gitignore | 19 + .idea/.gitignore | 2 + .idea/codeStyles/Project.xml | 116 ++ .idea/libraries/Dart_SDK.xml | 19 + .idea/libraries/Flutter_Plugins.xml | 15 + .idea/libraries/Flutter_for_Android.xml | 9 + .idea/modules.xml | 9 + .../example_lib_main_dart.xml | 6 + .idea/vcs.xml | 6 + .idea/workspace.xml | 67 + CHANGELOG.md | 254 +++ LICENSE | 21 + README.md | 269 +++ android/.gitignore | 8 + android/build.gradle | 39 + android/gradle.properties | 4 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54329 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + android/gradlew | 172 ++ android/gradlew.bat | 84 + android/settings.gradle | 1 + android/src/main/AndroidManifest.xml | 3 + .../audioservice/AudioInterruption.java | 8 + .../audioservice/AudioProcessingState.java | 16 + .../ryanheise/audioservice/AudioService.java | 805 ++++++++ .../audioservice/AudioServicePlugin.java | 1061 ++++++++++ .../audioservice/MediaButtonReceiver.java | 19 + .../ryanheise/audioservice/MediaControl.java | 7 + .../java/com/ryanheise/audioservice/Size.java | 11 + .../audio_service_fast_forward.png | Bin 0 -> 561 bytes .../audio_service_fast_rewind.png | Bin 0 -> 584 bytes .../res/drawable-hdpi/audio_service_pause.png | Bin 0 -> 157 bytes .../audio_service_play_arrow.png | Bin 0 -> 352 bytes .../drawable-hdpi/audio_service_skip_next.png | Bin 0 -> 379 bytes .../audio_service_skip_previous.png | Bin 0 -> 410 bytes .../res/drawable-hdpi/audio_service_stop.png | Bin 0 -> 121 bytes .../audio_service_fast_forward.png | Bin 0 -> 241 bytes .../audio_service_fast_rewind.png | Bin 0 -> 267 bytes .../res/drawable-mdpi/audio_service_pause.png | Bin 0 -> 170 bytes .../audio_service_play_arrow.png | Bin 0 -> 285 bytes .../drawable-mdpi/audio_service_skip_next.png | Bin 0 -> 344 bytes .../audio_service_skip_previous.png | Bin 0 -> 354 bytes .../res/drawable-mdpi/audio_service_stop.png | Bin 0 -> 114 bytes .../audio_service_fast_forward.png | Bin 0 -> 389 bytes .../audio_service_fast_rewind.png | Bin 0 -> 460 bytes .../drawable-xhdpi/audio_service_pause.png | Bin 0 -> 188 bytes .../audio_service_play_arrow.png | Bin 0 -> 538 bytes .../audio_service_skip_next.png | Bin 0 -> 602 bytes .../audio_service_skip_previous.png | Bin 0 -> 639 bytes .../res/drawable-xhdpi/audio_service_stop.png | Bin 0 -> 152 bytes .../audio_service_fast_forward.png | Bin 0 -> 684 bytes .../audio_service_fast_rewind.png | Bin 0 -> 770 bytes .../drawable-xxhdpi/audio_service_pause.png | Bin 0 -> 315 bytes .../audio_service_play_arrow.png | Bin 0 -> 720 bytes .../audio_service_skip_next.png | Bin 0 -> 832 bytes .../audio_service_skip_previous.png | Bin 0 -> 857 bytes .../drawable-xxhdpi/audio_service_stop.png | Bin 0 -> 252 bytes .../audio_service_fast_forward.png | Bin 0 -> 838 bytes .../audio_service_fast_rewind.png | Bin 0 -> 952 bytes .../drawable-xxxhdpi/audio_service_pause.png | Bin 0 -> 461 bytes .../audio_service_play_arrow.png | Bin 0 -> 1119 bytes .../audio_service_skip_next.png | Bin 0 -> 1243 bytes .../audio_service_skip_previous.png | Bin 0 -> 1276 bytes .../drawable-xxxhdpi/audio_service_stop.png | Bin 0 -> 316 bytes audio_service.iml | 18 + audio_service_android.iml | 30 + darwin/Classes/AudioServicePlugin.m | 617 ++++++ ios/.gitignore | 37 + ios/Assets/.gitkeep | 0 ios/Classes/AudioServicePlugin.h | 54 + ios/Classes/AudioServicePlugin.m | 617 ++++++ ios/audio_service.podspec | 21 + lib/audio_service.dart | 1765 +++++++++++++++++ lib/audio_service_web.dart | 354 ++++ lib/js/media_metadata.dart | 32 + lib/js/media_session_web.dart | 36 + macos/Classes/AudioServicePlugin.h | 54 + macos/Classes/AudioServicePlugin.m | 617 ++++++ macos/audio_service.podspec | 22 + pubspec.yaml | 34 + 87 files changed, 7529 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/documentation-request.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/frequently-asked-questions.md create mode 100644 .github/workflows/auto-close.yml create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/libraries/Dart_SDK.xml create mode 100644 .idea/libraries/Flutter_Plugins.xml create mode 100644 .idea/libraries/Flutter_for_Android.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/runConfigurations/example_lib_main_dart.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/workspace.xml create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 android/.gitignore create mode 100644 android/build.gradle create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle create mode 100644 android/src/main/AndroidManifest.xml create mode 100644 android/src/main/java/com/ryanheise/audioservice/AudioInterruption.java create mode 100644 android/src/main/java/com/ryanheise/audioservice/AudioProcessingState.java create mode 100644 android/src/main/java/com/ryanheise/audioservice/AudioService.java create mode 100644 android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java create mode 100644 android/src/main/java/com/ryanheise/audioservice/MediaButtonReceiver.java create mode 100644 android/src/main/java/com/ryanheise/audioservice/MediaControl.java create mode 100644 android/src/main/java/com/ryanheise/audioservice/Size.java create mode 100644 android/src/main/res/drawable-hdpi/audio_service_fast_forward.png create mode 100644 android/src/main/res/drawable-hdpi/audio_service_fast_rewind.png create mode 100644 android/src/main/res/drawable-hdpi/audio_service_pause.png create mode 100644 android/src/main/res/drawable-hdpi/audio_service_play_arrow.png create mode 100644 android/src/main/res/drawable-hdpi/audio_service_skip_next.png create mode 100644 android/src/main/res/drawable-hdpi/audio_service_skip_previous.png create mode 100644 android/src/main/res/drawable-hdpi/audio_service_stop.png create mode 100644 android/src/main/res/drawable-mdpi/audio_service_fast_forward.png create mode 100644 android/src/main/res/drawable-mdpi/audio_service_fast_rewind.png create mode 100644 android/src/main/res/drawable-mdpi/audio_service_pause.png create mode 100644 android/src/main/res/drawable-mdpi/audio_service_play_arrow.png create mode 100644 android/src/main/res/drawable-mdpi/audio_service_skip_next.png create mode 100644 android/src/main/res/drawable-mdpi/audio_service_skip_previous.png create mode 100644 android/src/main/res/drawable-mdpi/audio_service_stop.png create mode 100644 android/src/main/res/drawable-xhdpi/audio_service_fast_forward.png create mode 100644 android/src/main/res/drawable-xhdpi/audio_service_fast_rewind.png create mode 100644 android/src/main/res/drawable-xhdpi/audio_service_pause.png create mode 100644 android/src/main/res/drawable-xhdpi/audio_service_play_arrow.png create mode 100644 android/src/main/res/drawable-xhdpi/audio_service_skip_next.png create mode 100644 android/src/main/res/drawable-xhdpi/audio_service_skip_previous.png create mode 100644 android/src/main/res/drawable-xhdpi/audio_service_stop.png create mode 100644 android/src/main/res/drawable-xxhdpi/audio_service_fast_forward.png create mode 100644 android/src/main/res/drawable-xxhdpi/audio_service_fast_rewind.png create mode 100644 android/src/main/res/drawable-xxhdpi/audio_service_pause.png create mode 100644 android/src/main/res/drawable-xxhdpi/audio_service_play_arrow.png create mode 100644 android/src/main/res/drawable-xxhdpi/audio_service_skip_next.png create mode 100644 android/src/main/res/drawable-xxhdpi/audio_service_skip_previous.png create mode 100644 android/src/main/res/drawable-xxhdpi/audio_service_stop.png create mode 100644 android/src/main/res/drawable-xxxhdpi/audio_service_fast_forward.png create mode 100644 android/src/main/res/drawable-xxxhdpi/audio_service_fast_rewind.png create mode 100644 android/src/main/res/drawable-xxxhdpi/audio_service_pause.png create mode 100644 android/src/main/res/drawable-xxxhdpi/audio_service_play_arrow.png create mode 100644 android/src/main/res/drawable-xxxhdpi/audio_service_skip_next.png create mode 100644 android/src/main/res/drawable-xxxhdpi/audio_service_skip_previous.png create mode 100644 android/src/main/res/drawable-xxxhdpi/audio_service_stop.png create mode 100644 audio_service.iml create mode 100644 audio_service_android.iml create mode 100644 darwin/Classes/AudioServicePlugin.m create mode 100644 ios/.gitignore create mode 100644 ios/Assets/.gitkeep create mode 100644 ios/Classes/AudioServicePlugin.h create mode 100644 ios/Classes/AudioServicePlugin.m create mode 100644 ios/audio_service.podspec create mode 100644 lib/audio_service.dart create mode 100644 lib/audio_service_web.dart create mode 100644 lib/js/media_metadata.dart create mode 100644 lib/js/media_session_web.dart create mode 100644 macos/Classes/AudioServicePlugin.h create mode 100644 macos/Classes/AudioServicePlugin.m create mode 100644 macos/audio_service.podspec create mode 100644 pubspec.yaml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..738822d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: ryanheise diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..d183eef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,48 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: 1 backlog, bug +assignees: ryanheise + +--- + + +**Which API doesn't behave as documented, and how does it misbehave?** +Name here the specific methods or fields that are not behaving as documented, and explain clearly what is happening. + +**Minimal reproduction project** +Provide a link here using one of two options: +1. Fork this repository and modify the example to reproduce the bug, then provide a link here. +2. If the unmodified official example already reproduces the bug, just write "The example". + +**To Reproduce (i.e. user steps, not code)** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Error messages** + +``` +If applicable, copy & paste error message here, within the triple quotes to preserve formatting. +``` + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Runtime Environment (please complete the following information if relevant):** + - Device: [e.g. Samsung Galaxy Note 8] + - OS: [e.g. Android 8.0.0] + +**Flutter SDK version** +``` +insert output of "flutter doctor" here +``` + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..6284239 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Community Support + url: https://stackoverflow.com/search?q=audio_service + about: Ask for help on Stack Overflow. + - name: New to Flutter? + url: https://gitter.im/flutter/flutter + about: Chat with other Flutter developers on Gitter. diff --git a/.github/ISSUE_TEMPLATE/documentation-request.md b/.github/ISSUE_TEMPLATE/documentation-request.md new file mode 100644 index 0000000..1d61cd1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation-request.md @@ -0,0 +1,39 @@ +--- +name: Documentation request +about: Suggest an improvement to the documentation +title: '' +labels: 1 backlog, documentation +assignees: ryanheise + +--- + + + +**To which pages does your suggestion apply?** + +- Direct URL 1 +- Direct URL 2 +- ... + +**Quote the sentences(s) from the documentation to be improved (if any)** + +> Insert here. (Skip if you are proposing an entirely new section.) + +**Describe your suggestion** + +... diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..52c89a5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,38 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: 1 backlog, enhancement +assignees: ryanheise + +--- + + + + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/frequently-asked-questions.md b/.github/ISSUE_TEMPLATE/frequently-asked-questions.md new file mode 100644 index 0000000..bd5c8b9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/frequently-asked-questions.md @@ -0,0 +1,19 @@ +--- +name: Frequently Asked Questions +about: Suggest a new question for the Wiki FAQ +title: '' +labels: 1 backlog, question +assignees: ryanheise + +--- + +## Checklist + + + +- [ ] The question is not already in the FAQ. +- [ ] The question is not too narrow or specific to a particular application. + +## Suggested Question + +Write the question here. diff --git a/.github/workflows/auto-close.yml b/.github/workflows/auto-close.yml new file mode 100644 index 0000000..6e572b9 --- /dev/null +++ b/.github/workflows/auto-close.yml @@ -0,0 +1,12 @@ +name: Autocloser +on: [issues] +jobs: + autoclose: + runs-on: ubuntu-latest + steps: + - name: Autoclose issues that did not follow issue template + uses: roots/issue-closer-action@v1.1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + issue-close-message: "This issue was automatically closed because it did not follow the issue template." + issue-pattern: "Which API(.|[\\r\\n])*Minimal reproduction project(.|[\\r\\n])*To Reproduce|To which pages(.|[\\r\\n])*Describe your suggestion|Is your feature request(.|[\\r\\n])*Describe the solution you'd like" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e36448f --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +.DS_Store +.dart_tool/ + +.packages +.pub/ +pubspec.lock + +build/ + +doc/ +**/ios/Flutter/flutter_export_environment.sh +android/.project +example/android/.project +android/.classpath +android/.settings/org.eclipse.buildship.core.prefs +example/android/.settings/org.eclipse.buildship.core.prefs +example/android/app/.classpath +example/android/app/.project +example/android/app/.settings/org.eclipse.buildship.core.prefs diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..6437983 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,2 @@ +# Project exclude paths +/. \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..681f41a --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,116 @@ + + + + + + + +
+ + + + xmlns:android + + ^$ + + + +
+
+ + + + xmlns:.* + + ^$ + + + BY_NAME + +
+
+ + + + .*:id + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:name + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + name + + ^$ + + + +
+
+ + + + style + + ^$ + + + +
+
+ + + + .* + + ^$ + + + BY_NAME + +
+
+ + + + .* + + http://schemas.android.com/apk/res/android + + + ANDROID_ATTRIBUTE_ORDER + +
+
+ + + + .* + + .* + + + BY_NAME + +
+
+
+
+
+
\ No newline at end of file diff --git a/.idea/libraries/Dart_SDK.xml b/.idea/libraries/Dart_SDK.xml new file mode 100644 index 0000000..8c58ced --- /dev/null +++ b/.idea/libraries/Dart_SDK.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/libraries/Flutter_Plugins.xml b/.idea/libraries/Flutter_Plugins.xml new file mode 100644 index 0000000..95e59bc --- /dev/null +++ b/.idea/libraries/Flutter_Plugins.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/libraries/Flutter_for_Android.xml b/.idea/libraries/Flutter_for_Android.xml new file mode 100644 index 0000000..1807c7e --- /dev/null +++ b/.idea/libraries/Flutter_for_Android.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..6f2ae4b --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/.idea/runConfigurations/example_lib_main_dart.xml b/.idea/runConfigurations/example_lib_main_dart.xml new file mode 100644 index 0000000..5fd9159 --- /dev/null +++ b/.idea/runConfigurations/example_lib_main_dart.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..281400f --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1600368096000 + + + + + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9df4bf5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,254 @@ +## 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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..03aae63 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018-2020 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..64f4cd9 --- /dev/null +++ b/README.md @@ -0,0 +1,269 @@ +# audio_service + +This plugin wraps around your existing audio code to allow it to run in the background or with the screen turned off, and allows your app to interact with headset buttons, the Android lock screen and notification, iOS control center, wearables and Android Auto. It is suitable for: + +* Music players +* Text-to-speech readers +* Podcast players +* Navigators +* More! + +## How does this plugin work? + +You encapsulate your audio code in a background task which runs in a special isolate that continues to run when your UI is absent. Your background task implements callbacks to respond to playback requests coming from your Flutter UI, headset buttons, the lock screen, notification, iOS control center, car displays and smart watches: + +![audio_service_callbacks](https://user-images.githubusercontent.com/19899190/84386442-b305cc80-ac34-11ea-8c2f-1b4cb126a98d.png) + +You can implement these callbacks to play any sort of audio that is appropriate for your app, such as music files or streams, audio assets, text to speech, synthesised audio, or combinations of these. + +| Feature | Android | iOS | macOS | Web | +| ------- | :-------: | :-----: | :-----: | :-----: | +| background audio | ✅ | ✅ | ✅ | ✅ | +| headset clicks | ✅ | ✅ | ✅ | ✅ | +| start/stop/play/pause/seek/rate | ✅ | ✅ | ✅ | ✅ | +| fast forward/rewind | ✅ | ✅ | ✅ | ✅ | +| repeat/shuffle mode | ✅ | ✅ | ✅ | ✅ | +| queue manipulation, skip next/prev | ✅ | ✅ | ✅ | ✅ | +| custom actions | ✅ | ✅ | ✅ | ✅ | +| custom events | ✅ | ✅ | ✅ | ✅ | +| notifications/control center | ✅ | ✅ | ✅ | ✅ | +| lock screen controls | ✅ | ✅ | | ✅ | +| album art | ✅ | ✅ | ✅ | ✅ | +| Android Auto, Apple CarPlay | (untested) | ✅ | | | + +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). + +## Migrating to 0.14.0 + +Audio focus, interruptions (e.g. phone calls), mixing, ducking and the configuration of your app's audio category and attributes, are now handled by the [audio_session](https://pub.dev/packages/audio_session) package. Read the [Migration Guide](https://github.com/ryanheise/audio_service/wiki/Migration-Guide#0140) for details. + +## Can I make use of other plugins within the background audio task? + +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_radio](https://pub.dev/packages/flutter_radio), [flutter_tts](https://pub.dartlang.org/packages/flutter_tts), and others, within your background audio task. There are also plugins like [just_audio_service](https://github.com/yringler/just_audio_service) that provide default implementations of `BackgroundAudioTask` 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 + +### Background code + +Your audio code will run in a special background isolate, separate and detachable from your app's UI. To achieve this, define a subclass of `BackgroundAudioTask` that overrides a set of callbacks to respond to client requests: + +```dart +class MyBackgroundTask extends BackgroundAudioTask { + // Initialise your audio task. + onStart(Map params) {} + // Handle a request to stop audio and finish the task. + onStop() async {} + // Handle a request to play audio. + onPlay() {} + // Handle a request to pause audio. + onPause() {} + // Handle a headset button click (play/pause, skip next/prev). + onClick(MediaButton button) {} + // Handle a request to skip to the next queue item. + onSkipToNext() {} + // Handle a request to skip to the previous queue item. + onSkipToPrevious() {} + // Handle a request to seek to a position. + onSeekTo(Duration position) {} +} +``` + +You can implement these (and other) callbacks to play any type of audio depending on the requirements of your app. For example, if you are building a podcast player, you may have code such as the following: + +```dart +import 'package:just_audio/just_audio.dart'; +class PodcastBackgroundTask extends BackgroundAudioTask { + AudioPlayer _player = AudioPlayer(); + onPlay() async { + _player.play(); + // Show the media notification, and let all clients know what + // playback state and media item to display. + await AudioServiceBackground.setState(playing: true, ...); + await AudioServiceBackground.setMediaItem(MediaItem(title: "Hey Jude", ...)) + } +``` + +If you are instead building a text-to-speech reader, you may have code such as the following: + +```dart +import 'package:flutter_tts/flutter_tts.dart'; +class ReaderBackgroundTask extends BackgroundAudioTask { + FlutterTts _tts = FlutterTts(); + String article; + onPlay() async { + _tts.speak(article); + // Show the media notification, and let all clients know what + // playback state and media item to display. + await AudioServiceBackground.setState(playing: true, ...); + await AudioServiceBackground.setMediaItem(MediaItem(album: "Business Insider", ...)) + } +} +``` + +There are several methods in the `AudioServiceBackground` class that are made available to your background audio task to allow it to communicate to clients outside the isolate, such as your Flutter UI (if present), the iOS control center, the Android notification and lock screen. These are: + +* `AudioServiceBackground.setState` broadcasts the current playback state to all clients. This includes whether or not audio is playing, but also whether audio is buffering, the current playback position and buffer position, the current playback speed, and the set of audio controls that should be made available. When you broadcast this information to all clients, it allows them to update their user interfaces to show the appropriate set of buttons, and show the correct audio position on seek bars, for example. It is important for you to call this method whenever any of these pieces of state changes. You will typically want to call this method from your `onStart`, `onPlay`, `onPause`, `onSkipToNext`, `onSkipToPrevious` and `onStop` callbacks. +* `AudioServiceBackground.setMediaItem` broadcasts the currently playing media item to all clients. This includes the track title, artist, genre, duration, any artwork to display, and other information. When you broadcast this information to all clients, it allows them to update their user interface accordingly so that it is displayed on the lock screen, the notification, and in your Flutter UI (if present). You will typically want to call this method from your `onStart`, `onSkipToNext` and `onSkipToPrevious` callbacks. +* `AudioServiceBackground.setQueue` broadcasts the current queue to all clients. Some clients like Android Auto may display this information in their user interfaces. You will typically want to call this method from your `onStart` callback. Other callbacks exist where it may be appropriate to call this method such as `onAddQueueItem` and `onRemoveQueueItem`. + +### UI code + +Connecting to `AudioService`: + +```dart +// Wrap your "/" route's widget tree in an AudioServiceWidget: +return MaterialApp( + home: AudioServiceWidget(MainScreen()), +); +``` + +Starting your background audio task: + +```dart +await AudioService.start( + backgroundTaskEntrypoint: _myEntrypoint, + androidNotificationIcon: 'mipmap/ic_launcher', + // An example of passing custom parameters. + // These will be passed through to your `onStart` callback. + params: {'url': 'https://somewhere.com/sometrack.mp3'}, +); +// this must be a top-level function +void _myEntrypoint() => AudioServiceBackground.run(() => MyBackgroundTask()); +``` + +Sending messages to it: + +* `AudioService.play()` +* `AudioService.pause()` +* `AudioService.click()` +* `AudioService.skipToNext()` +* `AudioService.skipToPrevious()` +* `AudioService.seekTo(Duration(seconds: 53))` + +Shutting it down: + +```dart +// This will pass through to your `onStop` callback. +AudioService.stop(); +``` + +Reacting to state changes: + +* `AudioService.playbackStateStream` (e.g. playing/paused, buffering/ready) +* `AudioService.currentMediaItemStream` (metadata about the currently playing media item) +* `AudioService.queueStream` (the current queue/playlist) + +Keep in mind that your UI and background task run in separate isolates and do not share memory. The only way they communicate is via message passing. Your Flutter UI will only use the `AudioService` API to communicate with the background task, while your background task will only use the `AudioServiceBackground` API to interact with the clients, which include the Flutter UI. + +### Connecting to `AudioService` from the background + +You can also send messages to your background audio task from another background callback (e.g. android_alarm_manager) by manually connecting to it: + +```dart +await AudioService.connect(); // Note: the "await" is necessary! +AudioService.play(); +``` + +## 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 + +These instructions assume that your project follows the new project template introduced in Flutter 1.12. If your project was created prior to 1.12 and uses the old project structure, you can update your project to follow the [new project template](https://github.com/flutter/flutter/wiki/Upgrading-pre-1.12-Android-projects). + +Additionally: + +1. Edit your project's `AndroidManifest.xml` file to declare the permission to create a wake lock, and add component entries for the `` and ``: + +```xml + + + + + + + ... + + + + + + + + + + + + + + +``` + +2. Starting from Flutter 1.12, you will need to disable the `shrinkResources` setting in your `android/app/build.gradle` file, otherwise the icon resources used in the Android notification will be removed during the build: + +``` +android { + compileSdkVersion 28 + + ... + + buildTypes { + release { + signingConfig ... + shrinkResources false // ADD THIS LINE + } + } +} +``` + +## iOS setup + +Insert this in your `Info.plist` file: + +``` + UIBackgroundModes + + audio + +``` + +The example project may be consulted for context. + +## 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' +``` + +# Where can I find more information? + +* [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/blob/master/example/lib/main.dart): 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) diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..c6cbe56 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,8 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..de54c7d --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,39 @@ +group 'com.ryanheise.audioservice' +version '1.0-SNAPSHOT' + +buildscript { + repositories { + google() + jcenter() + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.5.0' + } +} + +rootProject.allprojects { + repositories { + google() + jcenter() + } +} + +apply plugin: 'com.android.library' + +android { + compileSdkVersion 28 + + defaultConfig { + minSdkVersion 16 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + lintOptions { + disable 'InvalidPackage' + } +} + +dependencies { + implementation 'androidx.core:core:1.1.0' + implementation 'androidx.media:media:1.1.0' +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..38c8d45 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx1536M +android.enableR8=true +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f6b961fd5a86aa5fbfe90f707c3138408be7c718 GIT binary patch literal 54329 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

<5G2M1WZP5 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 F{|7XC?P&l2 literal 0 HcmV?d00001 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..6023343 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Thu Sep 17 20:40:30 CEST 2020 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..cccdd3d --- /dev/null +++ b/android/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..e95643d --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..8fa78ff --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'audio_service' diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3731bd5 --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/android/src/main/java/com/ryanheise/audioservice/AudioInterruption.java b/android/src/main/java/com/ryanheise/audioservice/AudioInterruption.java new file mode 100644 index 0000000..f91eb91 --- /dev/null +++ b/android/src/main/java/com/ryanheise/audioservice/AudioInterruption.java @@ -0,0 +1,8 @@ +package com.ryanheise.audioservice; + +public enum AudioInterruption { + pause, + temporaryPause, + temporaryDuck, + unknownPause, +} diff --git a/android/src/main/java/com/ryanheise/audioservice/AudioProcessingState.java b/android/src/main/java/com/ryanheise/audioservice/AudioProcessingState.java new file mode 100644 index 0000000..fcb1ccf --- /dev/null +++ b/android/src/main/java/com/ryanheise/audioservice/AudioProcessingState.java @@ -0,0 +1,16 @@ +package com.ryanheise.audioservice; + +public enum AudioProcessingState { + none, + connecting, + ready, + buffering, + fastForwarding, + rewinding, + skippingToPrevious, + skippingToNext, + skippingToQueueItem, + completed, + stopped, + error, +} diff --git a/android/src/main/java/com/ryanheise/audioservice/AudioService.java b/android/src/main/java/com/ryanheise/audioservice/AudioService.java new file mode 100644 index 0000000..adb051a --- /dev/null +++ b/android/src/main/java/com/ryanheise/audioservice/AudioService.java @@ -0,0 +1,805 @@ +package com.ryanheise.audioservice; + +import android.app.Activity; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.media.MediaDescription; +import android.media.MediaMetadata; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.PowerManager; +import android.support.v4.media.MediaBrowserCompat; +import android.support.v4.media.MediaDescriptionCompat; +import android.support.v4.media.MediaMetadataCompat; +import android.support.v4.media.RatingCompat; +import android.support.v4.media.session.MediaControllerCompat; +import android.support.v4.media.session.MediaSessionCompat; +import android.support.v4.media.session.PlaybackStateCompat; +import android.util.Log; +import android.util.LruCache; +import android.view.KeyEvent; + +import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; +import androidx.media.MediaBrowserServiceCompat; +import androidx.media.app.NotificationCompat.MediaStyle; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class AudioService extends MediaBrowserServiceCompat { + private static final int NOTIFICATION_ID = 1124; + private static final int REQUEST_CONTENT_INTENT = 1000; + private static final String MEDIA_ROOT_ID = "root"; + // See the comment in onMediaButtonEvent to understand how the BYPASS keycodes work. + // We hijack KEYCODE_MUTE and KEYCODE_MEDIA_RECORD since the media session subsystem + // considers these keycodes relevant to media playback and will pass them on to us. + public static final int KEYCODE_BYPASS_PLAY = KeyEvent.KEYCODE_MUTE; + public static final int KEYCODE_BYPASS_PAUSE = KeyEvent.KEYCODE_MEDIA_RECORD; + public static final int MAX_COMPACT_ACTIONS = 3; + + private static volatile boolean running; + static AudioService instance; + private static PendingIntent contentIntent; + private static boolean resumeOnClick; + private static ServiceListener listener; + static String androidNotificationChannelName; + static String androidNotificationChannelDescription; + static Integer notificationColor; + static String androidNotificationIcon; + static boolean androidNotificationClickStartsActivity; + static boolean androidNotificationOngoing; + static boolean androidStopForegroundOnPause; + private static List queue = new ArrayList(); + private static int queueIndex = -1; + private static Map mediaMetadataCache = new HashMap<>(); + private static Set artUriBlacklist = new HashSet<>(); + private static LruCache artBitmapCache; + private static Size artDownscaleSize; + private static boolean playing = false; + private static AudioProcessingState processingState = AudioProcessingState.none; + private static int repeatMode; + private static int shuffleMode; + private static boolean notificationCreated; + + public static void init(Activity activity, boolean resumeOnClick, String androidNotificationChannelName, String androidNotificationChannelDescription, String action, Integer notificationColor, String androidNotificationIcon, boolean androidNotificationClickStartsActivity, boolean androidNotificationOngoing, boolean androidStopForegroundOnPause, Size artDownscaleSize, ServiceListener listener) { + if (running) + throw new IllegalStateException("AudioService already running"); + running = true; + + Context context = activity.getApplicationContext(); + Intent intent = new Intent(context, activity.getClass()); + intent.setAction(action); + contentIntent = PendingIntent.getActivity(context, REQUEST_CONTENT_INTENT, intent, PendingIntent.FLAG_UPDATE_CURRENT); + AudioService.listener = listener; + AudioService.resumeOnClick = resumeOnClick; + AudioService.androidNotificationChannelName = androidNotificationChannelName; + AudioService.androidNotificationChannelDescription = androidNotificationChannelDescription; + AudioService.notificationColor = notificationColor; + AudioService.androidNotificationIcon = androidNotificationIcon; + AudioService.androidNotificationClickStartsActivity = androidNotificationClickStartsActivity; + AudioService.androidNotificationOngoing = androidNotificationOngoing; + AudioService.androidStopForegroundOnPause = androidStopForegroundOnPause; + AudioService.artDownscaleSize = artDownscaleSize; + + notificationCreated = false; + playing = false; + processingState = AudioProcessingState.none; + repeatMode = 0; + shuffleMode = 0; + + // Get max available VM memory, exceeding this amount will throw an + // OutOfMemory exception. Stored in kilobytes as LruCache takes an + // int in its constructor. + final int maxMemory = (int)(Runtime.getRuntime().maxMemory() / 1024); + + // Use 1/8th of the available memory for this memory cache. + final int cacheSize = maxMemory / 8; + + artBitmapCache = new LruCache(cacheSize) { + @Override + protected int sizeOf(String key, Bitmap bitmap) { + // The cache size will be measured in kilobytes rather than + // number of items. + return bitmap.getByteCount() / 1024; + } + }; + } + + public static AudioProcessingState getProcessingState() { + return processingState; + } + + public static boolean isPlaying() { + return playing; + } + + public static int getRepeatMode() { + return repeatMode; + } + + public static int getShuffleMode() { + return shuffleMode; + } + + public void stop() { + running = false; + mediaMetadata = null; + resumeOnClick = false; + listener = null; + androidNotificationChannelName = null; + androidNotificationChannelDescription = null; + notificationColor = null; + androidNotificationIcon = null; + artDownscaleSize = null; + queue.clear(); + queueIndex = -1; + mediaMetadataCache.clear(); + actions.clear(); + artBitmapCache.evictAll(); + compactActionIndices = null; + + mediaSession.setQueue(queue); + mediaSession.setActive(false); + releaseWakeLock(); + stopForeground(true); + notificationCreated = false; + stopSelf(); + } + + public static boolean isRunning() { + return running; + } + + private PowerManager.WakeLock wakeLock; + private MediaSessionCompat mediaSession; + private MediaSessionCallback mediaSessionCallback; + private MediaMetadataCompat preparedMedia; + private List actions = new ArrayList(); + private int[] compactActionIndices; + private MediaMetadataCompat mediaMetadata; + private Object audioFocusRequest; + private String notificationChannelId; + private Handler handler = new Handler(Looper.getMainLooper()); + + int getResourceId(String resource) { + String[] parts = resource.split("/"); + String resourceType = parts[0]; + String resourceName = parts[1]; + return getResources().getIdentifier(resourceName, resourceType, getApplicationContext().getPackageName()); + } + + NotificationCompat.Action action(String resource, String label, long actionCode) { + int iconId = getResourceId(resource); + return new NotificationCompat.Action(iconId, label, + buildMediaButtonPendingIntent(actionCode)); + } + + PendingIntent buildMediaButtonPendingIntent(long action) { + int keyCode = toKeyCode(action); + if (keyCode == KeyEvent.KEYCODE_UNKNOWN) + return null; + Intent intent = new Intent(this, MediaButtonReceiver.class); + intent.setAction(Intent.ACTION_MEDIA_BUTTON); + intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, keyCode)); + return PendingIntent.getBroadcast(this, keyCode, intent, 0); + } + + PendingIntent buildDeletePendingIntent() { + Intent intent = new Intent(this, MediaButtonReceiver.class); + intent.setAction(MediaButtonReceiver.ACTION_NOTIFICATION_DELETE); + return PendingIntent.getBroadcast(this, 0, intent, 0); + } + + public static int toKeyCode(long action) { + if (action == PlaybackStateCompat.ACTION_PLAY) { + return KEYCODE_BYPASS_PLAY; + } else if (action == PlaybackStateCompat.ACTION_PAUSE) { + return KEYCODE_BYPASS_PAUSE; + } else { + return PlaybackStateCompat.toKeyCode(action); + } + } + + void setState(List actions, int actionBits, int[] compactActionIndices, AudioProcessingState processingState, boolean playing, long position, long bufferedPosition, float speed, long updateTime, int repeatMode, int shuffleMode) { + this.actions = actions; + this.compactActionIndices = compactActionIndices; + boolean wasPlaying = AudioService.playing; + AudioService.processingState = processingState; + AudioService.playing = playing; + AudioService.repeatMode = repeatMode; + AudioService.shuffleMode = shuffleMode; + + PlaybackStateCompat.Builder stateBuilder = new PlaybackStateCompat.Builder() + .setActions(PlaybackStateCompat.ACTION_PLAY_PAUSE | actionBits) + .setState(getPlaybackState(), position, speed, updateTime) + .setBufferedPosition(bufferedPosition); + mediaSession.setPlaybackState(stateBuilder.build()); + + if (!running) return; + + if (!wasPlaying && playing) { + enterPlayingState(); + } else if (wasPlaying && !playing) { + exitPlayingState(); + } + + updateNotification(); + } + + public int getPlaybackState() { + switch (processingState) { + case none: return PlaybackStateCompat.STATE_NONE; + case connecting: return PlaybackStateCompat.STATE_CONNECTING; + case ready: return playing ? PlaybackStateCompat.STATE_PLAYING : PlaybackStateCompat.STATE_PAUSED; + case buffering: return PlaybackStateCompat.STATE_BUFFERING; + case fastForwarding: return PlaybackStateCompat.STATE_FAST_FORWARDING; + case rewinding: return PlaybackStateCompat.STATE_REWINDING; + case skippingToPrevious: return PlaybackStateCompat.STATE_SKIPPING_TO_PREVIOUS; + case skippingToNext: return PlaybackStateCompat.STATE_SKIPPING_TO_NEXT; + case skippingToQueueItem: return PlaybackStateCompat.STATE_SKIPPING_TO_QUEUE_ITEM; + case completed: return playing ? PlaybackStateCompat.STATE_PLAYING : PlaybackStateCompat.STATE_PAUSED; + case stopped: return PlaybackStateCompat.STATE_STOPPED; + case error: return PlaybackStateCompat.STATE_ERROR; + default: return PlaybackStateCompat.STATE_NONE; + } + } + + private Notification buildNotification() { + int[] compactActionIndices = this.compactActionIndices; + if (compactActionIndices == null) { + compactActionIndices = new int[Math.min(MAX_COMPACT_ACTIONS, actions.size())]; + for (int i = 0; i < compactActionIndices.length; i++) compactActionIndices[i] = i; + } + NotificationCompat.Builder builder = getNotificationBuilder(); + if (mediaMetadata != null) { + MediaDescriptionCompat description = mediaMetadata.getDescription(); + if (description.getTitle() != null) + builder.setContentTitle(description.getTitle()); + if (description.getSubtitle() != null) + builder.setContentText(description.getSubtitle()); + if (description.getDescription() != null) + builder.setSubText(description.getDescription()); + if (description.getIconBitmap() != null) + builder.setLargeIcon(description.getIconBitmap()); + } + if (androidNotificationClickStartsActivity) + builder.setContentIntent(mediaSession.getController().getSessionActivity()); + if (notificationColor != null) + builder.setColor(notificationColor); + for (NotificationCompat.Action action : actions) { + builder.addAction(action); + } + builder.setStyle(new MediaStyle() + .setMediaSession(mediaSession.getSessionToken()) + .setShowActionsInCompactView(compactActionIndices) + .setShowCancelButton(true) + .setCancelButtonIntent(buildMediaButtonPendingIntent(PlaybackStateCompat.ACTION_STOP)) + ); + if (androidNotificationOngoing) + builder.setOngoing(true); + Notification notification = builder.build(); + return notification; + } + + private NotificationCompat.Builder getNotificationBuilder() { + NotificationCompat.Builder notificationBuilder = null; + if (notificationBuilder == null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + createChannel(); + int iconId = getResourceId(androidNotificationIcon); + notificationBuilder = new NotificationCompat.Builder(this, notificationChannelId) + .setSmallIcon(iconId) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setShowWhen(false) + .setDeleteIntent(buildDeletePendingIntent()) + ; + } + return notificationBuilder; + } + + public void handleDeleteNotification() { + if (listener == null) return; + listener.onClose(); + } + + + @RequiresApi(Build.VERSION_CODES.O) + private void createChannel() { + NotificationManager notificationManager = (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE); + NotificationChannel channel = notificationManager.getNotificationChannel(notificationChannelId); + if (channel == null) { + channel = new NotificationChannel(notificationChannelId, androidNotificationChannelName, NotificationManager.IMPORTANCE_LOW); + if (androidNotificationChannelDescription != null) + channel.setDescription(androidNotificationChannelDescription); + notificationManager.createNotificationChannel(channel); + } + } + + private void updateNotification() { + if (!notificationCreated) return; + NotificationManager notificationManager = (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.notify(NOTIFICATION_ID, buildNotification()); + } + + private boolean enterPlayingState() { + startService(new Intent(AudioService.this, AudioService.class)); + if (!mediaSession.isActive()) + mediaSession.setActive(true); + + acquireWakeLock(); + mediaSession.setSessionActivity(contentIntent); + internalStartForeground(); + return true; + } + + private void exitPlayingState() { + if (androidStopForegroundOnPause) { + exitForegroundState(); + } + } + + private void exitForegroundState() { + stopForeground(false); + releaseWakeLock(); + } + + private void internalStartForeground() { + startForeground(NOTIFICATION_ID, buildNotification()); + notificationCreated = true; + } + + private void acquireWakeLock() { + if (!wakeLock.isHeld()) + wakeLock.acquire(); + } + + private void releaseWakeLock() { + if (wakeLock.isHeld()) + wakeLock.release(); + } + + static MediaMetadataCompat createMediaMetadata(String mediaId, String album, String title, String artist, String genre, Long duration, String artUri, String displayTitle, String displaySubtitle, String displayDescription, RatingCompat rating, Map extras) { + MediaMetadataCompat.Builder builder = new MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, mediaId) + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album) + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title); + if (artist != null) + builder.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist); + if (genre != null) + builder.putString(MediaMetadataCompat.METADATA_KEY_GENRE, genre); + if (duration != null) + builder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration); + if (artUri != null) { + builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, artUri); + String artCacheFilePath = null; + if (extras != null) { + artCacheFilePath = (String)extras.get("artCacheFile"); + } + if (artCacheFilePath != null) { + Bitmap bitmap = loadArtBitmapFromFile(artCacheFilePath); + if (bitmap != null) { + builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap); + builder.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, bitmap); + } + } + } + if (displayTitle != null) + builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle); + if (displaySubtitle != null) + builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displaySubtitle); + if (displayDescription != null) + builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayDescription); + if (rating != null) { + builder.putRating(MediaMetadataCompat.METADATA_KEY_RATING, rating); + } + if (extras != null) { + for (Object o : extras.keySet()) { + String key = (String)o; + Object value = extras.get(key); + if (value instanceof Long) { + builder.putLong("extra_long_" + key, (Long)value); + } else if (value instanceof Integer) { + builder.putLong("extra_long_" + key, (Integer)value); + } else if (value instanceof String) { + builder.putString("extra_string_" + key, (String)value); + } + } + } + MediaMetadataCompat mediaMetadata = builder.build(); + mediaMetadataCache.put(mediaId, mediaMetadata); + return mediaMetadata; + } + + static MediaMetadataCompat getMediaMetadata(String mediaId) { + return mediaMetadataCache.get(mediaId); + } + + @Override + public void onCreate() { + super.onCreate(); + instance = this; + notificationChannelId = getApplication().getPackageName() + ".channel"; + + mediaSession = new MediaSessionCompat(this, "media-session"); + mediaSession.setMediaButtonReceiver(null); // TODO: Make this configurable + mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS); + PlaybackStateCompat.Builder stateBuilder = new PlaybackStateCompat.Builder() + .setActions(PlaybackStateCompat.ACTION_PLAY); + mediaSession.setPlaybackState(stateBuilder.build()); + mediaSession.setCallback(mediaSessionCallback = new MediaSessionCallback()); + setSessionToken(mediaSession.getSessionToken()); + mediaSession.setQueue(queue); + + PowerManager pm = (PowerManager)getSystemService(Context.POWER_SERVICE); + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, AudioService.class.getName()); + } + + void enableQueue() { + mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS | MediaSessionCompat.FLAG_HANDLES_QUEUE_COMMANDS); + } + + void setQueue(List queue) { + this.queue = queue; + mediaSession.setQueue(queue); + } + + void playMediaItem(MediaDescriptionCompat description) { + mediaSessionCallback.onPlayMediaItem(description); + } + + void setMetadata(final MediaMetadataCompat mediaMetadata) { + this.mediaMetadata = mediaMetadata; + mediaSession.setMetadata(mediaMetadata); + updateNotification(); + } + + static Bitmap loadArtBitmapFromFile(String path) { + Bitmap bitmap = artBitmapCache.get(path); + if (bitmap != null) return bitmap; + try { + if (artDownscaleSize != null) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeFile(path, options); + int imageHeight = options.outHeight; + int imageWidth = options.outWidth; + options.inSampleSize = calculateInSampleSize(options, artDownscaleSize.width, artDownscaleSize.height); + options.inJustDecodeBounds = false; + + bitmap = BitmapFactory.decodeFile(path, options); + } else { + bitmap = BitmapFactory.decodeFile(path); + } + artBitmapCache.put(path, bitmap); + return bitmap; + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + private static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { + final int height = options.outHeight; + final int width = options.outWidth; + int inSampleSize = 1; + + if (height > reqHeight || width > reqWidth) { + final int halfHeight = height / 2; + final int halfWidth = width / 2; + while ((halfHeight / inSampleSize) >= reqHeight + && (halfWidth / inSampleSize) >= reqWidth) { + inSampleSize *= 2; + } + } + + return inSampleSize; + } + + @Override + public BrowserRoot onGetRoot(String clientPackageName, int clientUid, Bundle rootHints) { + return new BrowserRoot(MEDIA_ROOT_ID, null); + } + + @Override + public void onLoadChildren(final String parentMediaId, final Result> result) { + if (listener == null) { + result.sendResult(new ArrayList()); + return; + } + listener.onLoadChildren(parentMediaId, result); + } + + @Override + public int onStartCommand(final Intent intent, int flags, int startId) { + MediaButtonReceiver.handleIntent(mediaSession, intent); + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (listener != null) { + listener.onDestroy(); + } + mediaSession.release(); + instance = null; + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + if (listener != null) { + listener.onTaskRemoved(); + } + super.onTaskRemoved(rootIntent); + } + + public class MediaSessionCallback extends MediaSessionCompat.Callback { + @Override + public void onAddQueueItem(MediaDescriptionCompat description) { + if (listener == null) return; + listener.onAddQueueItem(getMediaMetadata(description.getMediaId())); + } + + @Override + public void onAddQueueItem(MediaDescriptionCompat description, int index) { + if (listener == null) return; + listener.onAddQueueItemAt(getMediaMetadata(description.getMediaId()), index); + } + + @Override + public void onRemoveQueueItem(MediaDescriptionCompat description) { + if (listener == null) return; + listener.onRemoveQueueItem(getMediaMetadata(description.getMediaId())); + } + + @Override + public void onPrepare() { + if (listener == null) return; + if (!mediaSession.isActive()) + mediaSession.setActive(true); + listener.onPrepare(); + } + + @Override + public void onPlay() { + if (listener == null) return; + listener.onPlay(); + } + + @Override + public void onPrepareFromMediaId(String mediaId, Bundle extras) { + if (listener == null) return; + if (!mediaSession.isActive()) + mediaSession.setActive(true); + listener.onPrepareFromMediaId(mediaId); + } + + @Override + public void onPlayFromMediaId(final String mediaId, final Bundle extras) { + if (listener == null) return; + listener.onPlayFromMediaId(mediaId); + } + + @Override + public boolean onMediaButtonEvent(Intent mediaButtonEvent) { + if (listener == null) return false; + final KeyEvent event = (KeyEvent)mediaButtonEvent.getExtras().get(Intent.EXTRA_KEY_EVENT); + if (event.getAction() == KeyEvent.ACTION_DOWN) { + switch (event.getKeyCode()) { + case KEYCODE_BYPASS_PLAY: + onPlay(); + break; + case KEYCODE_BYPASS_PAUSE: + onPause(); + break; + case KeyEvent.KEYCODE_MEDIA_STOP: + onStop(); + break; + case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD: + onFastForward(); + break; + case KeyEvent.KEYCODE_MEDIA_REWIND: + onRewind(); + break; + // Android unfortunately reroutes media button clicks to + // KEYCODE_MEDIA_PLAY/PAUSE instead of the expected KEYCODE_HEADSETHOOK + // or KEYCODE_MEDIA_PLAY_PAUSE. As a result, we can't genuinely tell if + // onMediaButtonEvent was called because a media button was actually + // pressed or because a PLAY/PAUSE action was pressed instead! To get + // around this, we make PLAY and PAUSE actions use different keycodes: + // KEYCODE_BYPASS_PLAY/PAUSE. Now if we get KEYCODE_MEDIA_PLAY/PUASE + // we know it is actually a media button press. + case KeyEvent.KEYCODE_MEDIA_NEXT: + case KeyEvent.KEYCODE_MEDIA_PREVIOUS: + case KeyEvent.KEYCODE_MEDIA_PLAY: + case KeyEvent.KEYCODE_MEDIA_PAUSE: + // These are the "genuine" media button click events + case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: + case KeyEvent.KEYCODE_HEADSETHOOK: + MediaControllerCompat controller = mediaSession.getController(); + listener.onClick(mediaControl(event)); + break; + } + } + return true; + } + + private MediaControl mediaControl(KeyEvent event) { + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: + case KeyEvent.KEYCODE_HEADSETHOOK: + return MediaControl.media; + case KeyEvent.KEYCODE_MEDIA_NEXT: + return MediaControl.next; + case KeyEvent.KEYCODE_MEDIA_PREVIOUS: + return MediaControl.previous; + default: + return MediaControl.media; + } + } + + @Override + public void onPause() { + if (listener == null) return; + listener.onPause(); + } + + @Override + public void onStop() { + if (listener == null) return; + listener.onStop(); + } + + @Override + public void onSkipToNext() { + if (listener == null) return; + listener.onSkipToNext(); + } + + @Override + public void onSkipToPrevious() { + if (listener == null) return; + listener.onSkipToPrevious(); + } + + @Override + public void onFastForward() { + if (listener == null) return; + listener.onFastForward(); + } + + @Override + public void onRewind() { + if (listener == null) return; + listener.onRewind(); + } + + @Override + public void onSkipToQueueItem(long id) { + if (listener == null) return; + listener.onSkipToQueueItem(id); + } + + @Override + public void onSeekTo(long pos) { + if (listener == null) return; + listener.onSeekTo(pos); + } + + @Override + public void onSetRating(RatingCompat rating) { + if (listener == null) return; + listener.onSetRating(rating); + } + + @Override + public void onSetRepeatMode(int repeatMode) { + if (listener == null) return; + listener.onSetRepeatMode(repeatMode); + } + + @Override + public void onSetShuffleMode(int shuffleMode) { + if (listener == null) return; + listener.onSetShuffleMode(shuffleMode); + } + + @Override + public void onSetRating(RatingCompat rating, Bundle extras) { + if (listener == null) return; + listener.onSetRating(rating, extras); + } + + // + // NON-STANDARD METHODS + // + + public void onPlayMediaItem(final MediaDescriptionCompat description) { + if (listener == null) return; + listener.onPlayMediaItem(getMediaMetadata(description.getMediaId())); + } + } + + public static interface ServiceListener { + void onLoadChildren(String parentMediaId, Result> result); + + void onClick(MediaControl mediaControl); + + void onPrepare(); + + void onPrepareFromMediaId(String mediaId); + + //void onPrepareFromSearch(String query); + //void onPrepareFromUri(String uri); + void onPlay(); + + void onPlayFromMediaId(String mediaId); + + //void onPlayFromSearch(String query, Map extras); + //void onPlayFromUri(String uri, Map extras); + void onSkipToQueueItem(long id); + + void onPause(); + + void onSkipToNext(); + + void onSkipToPrevious(); + + void onFastForward(); + + void onRewind(); + + void onStop(); + + void onDestroy(); + + void onSeekTo(long pos); + + void onSetRating(RatingCompat rating); + + void onSetRating(RatingCompat rating, Bundle extras); + + void onSetRepeatMode(int repeatMode); + + //void onSetShuffleModeEnabled(boolean enabled); + + void onSetShuffleMode(int shuffleMode); + + //void onCustomAction(String action, Bundle extras); + + void onAddQueueItem(MediaMetadataCompat metadata); + + void onAddQueueItemAt(MediaMetadataCompat metadata, int index); + + void onRemoveQueueItem(MediaMetadataCompat metadata); + + // + // NON-STANDARD METHODS + // + + void onPlayMediaItem(MediaMetadataCompat metadata); + + void onTaskRemoved(); + + void onClose(); + } +} diff --git a/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java b/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java new file mode 100644 index 0000000..eb04bd5 --- /dev/null +++ b/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java @@ -0,0 +1,1061 @@ +package com.ryanheise.audioservice; + +import io.flutter.embedding.engine.plugins.service.*; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioTrack; +import android.os.Bundle; +import android.os.SystemClock; + +import androidx.core.app.NotificationCompat; + +import android.support.v4.media.MediaBrowserCompat; + +import androidx.media.MediaBrowserServiceCompat; + +import android.support.v4.media.MediaDescriptionCompat; +import android.support.v4.media.MediaMetadataCompat; +import android.support.v4.media.RatingCompat; +import android.support.v4.media.session.MediaControllerCompat; +import android.support.v4.media.session.MediaSessionCompat; +import android.support.v4.media.session.PlaybackStateCompat; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.flutter.app.FlutterApplication; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodChannel.MethodCallHandler; +import io.flutter.plugin.common.MethodChannel.Result; +import io.flutter.plugin.common.PluginRegistry.NewIntentListener; +import io.flutter.plugin.common.PluginRegistry.PluginRegistrantCallback; +import io.flutter.plugin.common.PluginRegistry.Registrar; +import io.flutter.plugin.common.PluginRegistry.ViewDestroyListener; +import io.flutter.view.FlutterCallbackInformation; +import io.flutter.view.FlutterMain; +import io.flutter.embedding.engine.plugins.FlutterPlugin; + +import androidx.annotation.NonNull; + +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; +import io.flutter.embedding.engine.plugins.activity.ActivityAware; +import io.flutter.plugin.common.BinaryMessenger; + +import android.app.Service; + +import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.embedding.engine.plugins.shim.ShimPluginRegistry; +import io.flutter.embedding.engine.dart.DartExecutor; +import io.flutter.embedding.engine.dart.DartExecutor.DartCallback; + +import android.content.res.AssetManager; + +import io.flutter.view.FlutterNativeView; +import io.flutter.view.FlutterRunArguments; + +/** + * AudioservicePlugin + */ +public class AudioServicePlugin implements FlutterPlugin, ActivityAware { + private static final String CHANNEL_AUDIO_SERVICE = "ryanheise.com/audioService"; + private static final String CHANNEL_AUDIO_SERVICE_BACKGROUND = "ryanheise.com/audioServiceBackground"; + private static final String NOTIFICATION_CLICK_ACTION = "com.ryanheise.audioservice.NOTIFICATION_CLICK"; + + private static PluginRegistrantCallback pluginRegistrantCallback; + private static Set clientHandlers = new HashSet(); + private static ClientHandler mainClientHandler; + private static BackgroundHandler backgroundHandler; + private static FlutterEngine backgroundFlutterEngine; + private static int nextQueueItemId = 0; + private static List queueMediaIds = new ArrayList(); + private static Map queueItemIds = new HashMap(); + private static volatile Result connectResult; + private static volatile Result startResult; + private static volatile Result stopResult; + private static String subscribedParentMediaId; + private static long bootTime; + + static { + bootTime = System.currentTimeMillis() - SystemClock.elapsedRealtime(); + } + + static BackgroundHandler backgroundHandler() throws Exception { + if (backgroundHandler == null) throw new Exception("Background audio task not running"); + return backgroundHandler; + } + + public static void setPluginRegistrantCallback(PluginRegistrantCallback pluginRegistrantCallback) { + AudioServicePlugin.pluginRegistrantCallback = pluginRegistrantCallback; + } + + /** + * v1 plugin registration. + */ + public static void registerWith(Registrar registrar) { + if (registrar.activity() != null) { + mainClientHandler = new ClientHandler(registrar.messenger()); + mainClientHandler.setActivity(registrar.activity()); + mainClientHandler.setContext(registrar.activity()); + clientHandlers.add(mainClientHandler); + registrar.addViewDestroyListener(new ViewDestroyListener() { + @Override + public boolean onViewDestroy(FlutterNativeView view) { + mainClientHandler = null; + clientHandlers.remove(mainClientHandler); + return false; + } + }); + } else { + backgroundHandler.init(registrar.messenger()); + } + } + + private FlutterPluginBinding flutterPluginBinding; + private ActivityPluginBinding activityPluginBinding; + private NewIntentListener newIntentListener; + private ClientHandler clientHandler; // v2 only + + // + // FlutterPlugin callbacks + // + + @Override + public void onAttachedToEngine(FlutterPluginBinding binding) { + flutterPluginBinding = binding; + clientHandler = new ClientHandler(flutterPluginBinding.getBinaryMessenger()); + clientHandler.setContext(flutterPluginBinding.getApplicationContext()); + clientHandlers.add(clientHandler); + } + + @Override + public void onDetachedFromEngine(FlutterPluginBinding binding) { + clientHandlers.remove(clientHandler); + clientHandler.setContext(null); + flutterPluginBinding = null; + clientHandler = null; + } + + // + // ActivityAware callbacks + // + + @Override + public void onAttachedToActivity(ActivityPluginBinding binding) { + activityPluginBinding = binding; + clientHandler.setActivity(binding.getActivity()); + clientHandler.setContext(binding.getActivity()); + mainClientHandler = clientHandler; + registerOnNewIntentListener(); + } + + @Override + public void onDetachedFromActivityForConfigChanges() { + activityPluginBinding.removeOnNewIntentListener(newIntentListener); + } + + @Override + public void onReattachedToActivityForConfigChanges(ActivityPluginBinding binding) { + activityPluginBinding = binding; + clientHandler.setActivity(binding.getActivity()); + clientHandler.setContext(binding.getActivity()); + registerOnNewIntentListener(); + } + + @Override + public void onDetachedFromActivity() { + activityPluginBinding.removeOnNewIntentListener(newIntentListener); + newIntentListener = null; + activityPluginBinding = null; + clientHandler.setActivity(null); + clientHandler.setContext(flutterPluginBinding.getApplicationContext()); + if (clientHandler == mainClientHandler) { + mainClientHandler = null; + } + } + + private void registerOnNewIntentListener() { + activityPluginBinding.addOnNewIntentListener(newIntentListener = new NewIntentListener() { + @Override + public boolean onNewIntent(Intent intent) { + clientHandler.activity.setIntent(intent); + return true; + } + }); + } + + + private static void sendConnectResult(boolean result) { + if (connectResult != null) { + connectResult.success(result); + connectResult = null; + } + } + + private static void sendStartResult(boolean result) { + if (startResult != null) { + startResult.success(result); + startResult = null; + } + } + + private static void sendStopResult(boolean result) { + if (stopResult != null) { + stopResult.success(result); + stopResult = null; + } + } + + private static class ClientHandler implements MethodCallHandler { + private Context context; + private Activity activity; + private MethodChannel channel; + private boolean initEnginePending; + public long fastForwardInterval; + public long rewindInterval; + public Map params; + public MediaBrowserCompat mediaBrowser; + public MediaControllerCompat mediaController; + public MediaControllerCompat.Callback controllerCallback = new MediaControllerCompat.Callback() { + @Override + public void onMetadataChanged(MediaMetadataCompat metadata) { + invokeMethod("onMediaChanged", mediaMetadata2raw(metadata)); + } + + @Override + public void onPlaybackStateChanged(PlaybackStateCompat state) { + // On the native side, we represent the update time relative to the boot time. + // On the flutter side, we represent the update time relative to the epoch. + long updateTimeSinceBoot = state.getLastPositionUpdateTime(); + long updateTimeSinceEpoch = bootTime + updateTimeSinceBoot; + invokeMethod("onPlaybackStateChanged", AudioService.getProcessingState().ordinal(), AudioService.isPlaying(), state.getActions(), state.getPosition(), state.getBufferedPosition(), state.getPlaybackSpeed(), updateTimeSinceEpoch, AudioService.getRepeatMode(), AudioService.getShuffleMode()); + } + + @Override + public void onQueueChanged(List queue) { + invokeMethod("onQueueChanged", queue2raw(queue)); + } + }; + + private final MediaBrowserCompat.SubscriptionCallback subscriptionCallback = new MediaBrowserCompat.SubscriptionCallback() { + @Override + public void onChildrenLoaded(String parentId, List children) { + invokeMethod("onChildrenLoaded", mediaItems2raw(children)); + } + }; + + private final MediaBrowserCompat.ConnectionCallback connectionCallback = new MediaBrowserCompat.ConnectionCallback() { + @Override + public void onConnected() { + try { + //Activity activity = registrar.activity(); + MediaSessionCompat.Token token = mediaBrowser.getSessionToken(); + mediaController = new MediaControllerCompat(context, token); + if (activity != null) { + MediaControllerCompat.setMediaController(activity, mediaController); + } + mediaController.registerCallback(controllerCallback); + PlaybackStateCompat state = mediaController.getPlaybackState(); + controllerCallback.onPlaybackStateChanged(state); + MediaMetadataCompat metadata = mediaController.getMetadata(); + controllerCallback.onQueueChanged(mediaController.getQueue()); + controllerCallback.onMetadataChanged(metadata); + + synchronized (this) { + if (initEnginePending) { + backgroundHandler.initEngine(); + initEnginePending = false; + } + } + sendConnectResult(true); + } catch (Exception e) { + sendConnectResult(false); + throw new RuntimeException(e); + } + } + + @Override + public void onConnectionSuspended() { + // TODO: Handle this + } + + @Override + public void onConnectionFailed() { + sendConnectResult(false); + } + }; + + public ClientHandler(BinaryMessenger messenger) { + channel = new MethodChannel(messenger, CHANNEL_AUDIO_SERVICE); + channel.setMethodCallHandler(this); + } + + private void setContext(Context context) { + this.context = context; + } + + private void setActivity(Activity activity) { + this.activity = activity; + } + + // See: https://stackoverflow.com/questions/13135545/android-activity-is-using-old-intent-if-launching-app-from-recent-task + protected boolean wasLaunchedFromRecents() { + return (activity.getIntent().getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY; + } + + @Override + public void onMethodCall(MethodCall call, final Result result) { + try { + switch (call.method) { + case "isRunning": + result.success(AudioService.isRunning()); + break; + case "start": { + if (startResult != null) { + sendStartResult(false); + break; + } + startResult = result; // The result will be sent after the background task actually starts. + if (AudioService.isRunning() || backgroundHandler != null) { + sendStartResult(false); + break; + } + if (activity == null) { + System.out.println("AudioService can only be started from an activity"); + sendStartResult(false); + break; + } + Map arguments = (Map)call.arguments; + final long callbackHandle = getLong(arguments.get("callbackHandle")); + params = (Map)arguments.get("params"); + boolean androidNotificationClickStartsActivity = (Boolean)arguments.get("androidNotificationClickStartsActivity"); + boolean androidNotificationOngoing = (Boolean)arguments.get("androidNotificationOngoing"); + boolean androidResumeOnClick = (Boolean)arguments.get("androidResumeOnClick"); + String androidNotificationChannelName = (String)arguments.get("androidNotificationChannelName"); + String androidNotificationChannelDescription = (String)arguments.get("androidNotificationChannelDescription"); + Integer androidNotificationColor = arguments.get("androidNotificationColor") == null ? null : getInt(arguments.get("androidNotificationColor")); + String androidNotificationIcon = (String)arguments.get("androidNotificationIcon"); + final boolean androidEnableQueue = (Boolean)arguments.get("androidEnableQueue"); + final boolean androidStopForegroundOnPause = (Boolean)arguments.get("androidStopForegroundOnPause"); + final Map artDownscaleSizeMap = (Map)arguments.get("androidArtDownscaleSize"); + final Size artDownscaleSize = artDownscaleSizeMap == null ? null + : new Size((int)Math.round(artDownscaleSizeMap.get("width")), (int)Math.round(artDownscaleSizeMap.get("height"))); + fastForwardInterval = getLong(arguments.get("fastForwardInterval")); + rewindInterval = getLong(arguments.get("rewindInterval")); + + final String appBundlePath = FlutterMain.findAppBundlePath(context.getApplicationContext()); + backgroundHandler = new BackgroundHandler(callbackHandle, appBundlePath, androidEnableQueue); + AudioService.init(activity, androidResumeOnClick, androidNotificationChannelName, androidNotificationChannelDescription, NOTIFICATION_CLICK_ACTION, androidNotificationColor, androidNotificationIcon, androidNotificationClickStartsActivity, androidNotificationOngoing, androidStopForegroundOnPause, artDownscaleSize, backgroundHandler); + + synchronized (connectionCallback) { + if (mediaController != null) + backgroundHandler.initEngine(); + else + initEnginePending = true; + } + + break; + } + case "connect": + if (connectResult != null) { + result.success(false); + break; + } + if (activity != null) { + if (wasLaunchedFromRecents()) { + // We do this to avoid using the old intent. + activity.setIntent(new Intent(Intent.ACTION_MAIN)); + } + if (activity.getIntent().getAction() != null) + invokeMethod("notificationClicked", activity.getIntent().getAction().equals(NOTIFICATION_CLICK_ACTION)); + } + if (mediaBrowser == null) { + connectResult = result; + mediaBrowser = new MediaBrowserCompat(context, + new ComponentName(context, AudioService.class), + connectionCallback, + null); + mediaBrowser.connect(); + } else { + result.success(true); + } + break; + case "disconnect": + // Since the activity enters paused state, we set the intent with ACTION_MAIN. + activity.setIntent(new Intent(Intent.ACTION_MAIN)); + + if (mediaController != null) { + mediaController.unregisterCallback(controllerCallback); + mediaController = null; + } + if (subscribedParentMediaId != null) { + mediaBrowser.unsubscribe(subscribedParentMediaId); + subscribedParentMediaId = null; + } + if (mediaBrowser != null) { + mediaBrowser.disconnect(); + mediaBrowser = null; + } + result.success(true); + break; + case "setBrowseMediaParent": + String parentMediaId = (String)call.arguments; + // If the ID has changed, unsubscribe from the old one + if (subscribedParentMediaId != null && !subscribedParentMediaId.equals(parentMediaId)) { + mediaBrowser.unsubscribe(subscribedParentMediaId); + subscribedParentMediaId = null; + } + // Subscribe to the new one. + // Don't subscribe if we're still holding onto the old one + // Don't subscribe if the new ID is null. + if (subscribedParentMediaId == null && parentMediaId != null) { + subscribedParentMediaId = parentMediaId; + mediaBrowser.subscribe(parentMediaId, subscriptionCallback); + } + // If the new ID is null, send clients an empty list + if (subscribedParentMediaId == null) { + subscriptionCallback.onChildrenLoaded(subscribedParentMediaId, new ArrayList()); + } + result.success(true); + break; + case "addQueueItem": { + Map rawMediaItem = (Map)call.arguments; + // Cache item + createMediaMetadata(rawMediaItem); + // Pass through + backgroundHandler().invokeMethod(result, "onAddQueueItem", call.arguments); + break; + } + case "addQueueItemAt": { + List queueAndIndex = (List)call.arguments; + Map rawMediaItem = (Map)queueAndIndex.get(0); + int index = (Integer)queueAndIndex.get(1); + // Cache item + createMediaMetadata(rawMediaItem); + // Pass through + backgroundHandler().invokeMethod(result, "onAddQueueItemAt", rawMediaItem, index); + break; + } + case "removeQueueItem": { + Map rawMediaItem = (Map)call.arguments; + // Cache item + createMediaMetadata(rawMediaItem); + // Pass through + backgroundHandler().invokeMethod(result, "onRemoveQueueItem", call.arguments); + break; + } + case "updateQueue": { + backgroundHandler().invokeMethod(result, "onUpdateQueue", call.arguments); + break; + } + case "updateMediaItem": { + backgroundHandler().invokeMethod(result, "onUpdateMediaItem", call.arguments); + break; + } + //case "setVolumeTo" + //case "adjustVolume" + case "click": + int buttonIndex = (int)call.arguments; + backgroundHandler().invokeMethod(result, "onClick", buttonIndex); + break; + case "prepare": + backgroundHandler().invokeMethod(result, "onPrepare"); + break; + case "prepareFromMediaId": { + String mediaId = (String)call.arguments; + backgroundHandler().invokeMethod(result, "onPrepareFromMediaId", mediaId); + break; + } + //prepareFromSearch + //prepareFromUri + case "play": + backgroundHandler().invokeMethod(result, "onPlay"); + break; + case "playFromMediaId": { + String mediaId = (String)call.arguments; + backgroundHandler().invokeMethod(result, "onPlayFromMediaId", mediaId); + break; + } + case "playMediaItem": { + Map rawMediaItem = (Map)call.arguments; + // Cache item + createMediaMetadata(rawMediaItem); + // Pass through + backgroundHandler().invokeMethod(result, "onPlayMediaItem", call.arguments); + break; + } + //playFromSearch + //playFromUri + case "skipToQueueItem": { + String mediaId = (String)call.arguments; + backgroundHandler().invokeMethod(result, "onSkipToQueueItem", mediaId); + break; + } + case "pause": + backgroundHandler().invokeMethod(result, "onPause"); + break; + case "stop": + if (stopResult != null) { + result.success(false); + break; + } + if (backgroundHandler == null) { + result.success(false); + break; + } + stopResult = result; + backgroundHandler.invokeMethod("onStop"); + break; + case "seekTo": + int pos = (Integer)call.arguments; + backgroundHandler().invokeMethod(result, "onSeekTo", pos); + break; + case "skipToNext": + backgroundHandler().invokeMethod(result, "onSkipToNext"); + break; + case "skipToPrevious": + backgroundHandler().invokeMethod(result, "onSkipToPrevious"); + break; + case "fastForward": + backgroundHandler().invokeMethod(result, "onFastForward"); + break; + case "rewind": + backgroundHandler().invokeMethod(result, "onRewind"); + break; + case "setRepeatMode": + int repeatMode = (Integer)call.arguments; + backgroundHandler().invokeMethod(result, "onSetRepeatMode", repeatMode); + break; + case "setShuffleMode": + int shuffleMode = (Integer)call.arguments; + backgroundHandler().invokeMethod(result, "onSetShuffleMode", shuffleMode); + break; + case "setRating": + HashMap arguments = (HashMap)call.arguments; + backgroundHandler().invokeMethod(result, "onSetRating", arguments.get("rating"), arguments.get("extras")); + break; + case "setSpeed": + float speed = (float)((double)((Double)call.arguments)); + backgroundHandler().invokeMethod(result, "onSetSpeed", speed); + break; + case "seekForward": { + boolean begin = (Boolean)call.arguments; + backgroundHandler().invokeMethod(result, "onSeekForward", begin); + break; + } + case "seekBackward": { + boolean begin = (Boolean)call.arguments; + backgroundHandler().invokeMethod(result, "onSeekBackward", begin); + break; + } + default: + backgroundHandler().channel.invokeMethod(call.method, call.arguments, result); + break; + } + } catch (Exception e) { + result.error(e.getMessage(), null, null); + } + } + + public void invokeMethod(String method, Object... args) { + ArrayList list = new ArrayList(Arrays.asList(args)); + channel.invokeMethod(method, list); + } + } + + private static class BackgroundHandler implements MethodCallHandler, AudioService.ServiceListener { + private long callbackHandle; + private String appBundlePath; + private boolean enableQueue; + public MethodChannel channel; + private AudioTrack silenceAudioTrack; + private static final int SILENCE_SAMPLE_RATE = 44100; + private byte[] silence; + + public BackgroundHandler(long callbackHandle, String appBundlePath, boolean enableQueue) { + this.callbackHandle = callbackHandle; + this.appBundlePath = appBundlePath; + this.enableQueue = enableQueue; + } + + public void init(BinaryMessenger messenger) { + if (channel != null) return; + channel = new MethodChannel(messenger, CHANNEL_AUDIO_SERVICE_BACKGROUND); + channel.setMethodCallHandler(this); + } + + public void initEngine() { + Context context = AudioService.instance; + backgroundFlutterEngine = new FlutterEngine(context.getApplicationContext()); + FlutterCallbackInformation cb = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle); + if (cb == null || appBundlePath == null) { + sendStartResult(false); + return; + } + if (enableQueue) + AudioService.instance.enableQueue(); + // Register plugins in background isolate if app is using v1 embedding + if (pluginRegistrantCallback != null) { + pluginRegistrantCallback.registerWith(new ShimPluginRegistry(backgroundFlutterEngine)); + } + + DartExecutor executor = backgroundFlutterEngine.getDartExecutor(); + init(executor); + DartCallback dartCallback = new DartCallback(context.getAssets(), appBundlePath, cb); + + executor.executeDartCallback(dartCallback); + } + + @Override + public void onLoadChildren(final String parentMediaId, final MediaBrowserServiceCompat.Result> result) { + ArrayList list = new ArrayList(); + list.add(parentMediaId); + if (backgroundHandler != null) { + backgroundHandler.channel.invokeMethod("onLoadChildren", list, new MethodChannel.Result() { + @Override + public void error(String errorCode, String errorMessage, Object errorDetails) { + result.sendError(new Bundle()); + } + + @Override + public void notImplemented() { + result.sendError(new Bundle()); + } + + @Override + public void success(Object obj) { + List> rawMediaItems = (List>)obj; + List mediaItems = new ArrayList(); + for (Map rawMediaItem : rawMediaItems) { + MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem); + mediaItems.add(new MediaBrowserCompat.MediaItem(mediaMetadata.getDescription(), (Boolean)rawMediaItem.get("playable") ? MediaBrowserCompat.MediaItem.FLAG_PLAYABLE : MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)); + } + result.sendResult(mediaItems); + } + }); + } + result.detach(); + } + + @Override + public void onClick(MediaControl mediaControl) { + invokeMethod("onClick", mediaControl.ordinal()); + } + + @Override + public void onPause() { + invokeMethod("onPause"); + } + + @Override + public void onPrepare() { + invokeMethod("onPrepare"); + } + + @Override + public void onPrepareFromMediaId(String mediaId) { + invokeMethod("onPrepareFromMediaId", mediaId); + } + + @Override + public void onPlay() { + if (backgroundFlutterEngine == null) { + initEngine(); + } else + invokeMethod("onPlay"); + } + + @Override + public void onPlayFromMediaId(String mediaId) { + invokeMethod("onPlayFromMediaId", mediaId); + } + + @Override + public void onPlayMediaItem(MediaMetadataCompat metadata) { + invokeMethod("onPlayMediaItem", mediaMetadata2raw(metadata)); + } + + @Override + public void onStop() { + invokeMethod("onStop"); + } + + @Override + public void onDestroy() { + clear(); + } + + @Override + public void onAddQueueItem(MediaMetadataCompat metadata) { + invokeMethod("onAddQueueItem", mediaMetadata2raw(metadata)); + } + + @Override + public void onAddQueueItemAt(MediaMetadataCompat metadata, int index) { + invokeMethod("onAddQueueItemAt", mediaMetadata2raw(metadata), index); + } + + @Override + public void onRemoveQueueItem(MediaMetadataCompat metadata) { + invokeMethod("onRemoveQueueItem", mediaMetadata2raw(metadata)); + } + + @Override + public void onSkipToQueueItem(long queueItemId) { + String mediaId = queueMediaIds.get((int)queueItemId); + invokeMethod("onSkipToQueueItem", mediaId); + } + + @Override + public void onSkipToNext() { + invokeMethod("onSkipToNext"); + } + + @Override + public void onSkipToPrevious() { + invokeMethod("onSkipToPrevious"); + } + + @Override + public void onFastForward() { + invokeMethod("onFastForward"); + } + + @Override + public void onRewind() { + invokeMethod("onRewind"); + } + + @Override + public void onSeekTo(long pos) { + invokeMethod("onSeekTo", pos); + } + + @Override + public void onSetRepeatMode(int repeatMode) { + invokeMethod("onSetRepeatMode", repeatMode); + } + + @Override + public void onSetShuffleMode(int shuffleMode) { + invokeMethod("onSetShuffleMode", shuffleMode); + } + + @Override + public void onSetRating(RatingCompat rating) { + invokeMethod("onSetRating", rating2raw(rating), null); + } + + @Override + public void onSetRating(RatingCompat rating, Bundle extras) { + invokeMethod("onSetRating", rating2raw(rating), extras.getSerializable("extrasMap")); + } + + @Override + public void onTaskRemoved() { + invokeMethod("onTaskRemoved"); + } + + @Override + public void onClose() { + invokeMethod("onClose"); + } + + @Override + public void onMethodCall(MethodCall call, Result result) { + Context context = AudioService.instance; + switch (call.method) { + case "ready": + Map startParams = new HashMap(); + startParams.put("fastForwardInterval", mainClientHandler.fastForwardInterval); + startParams.put("rewindInterval", mainClientHandler.rewindInterval); + startParams.put("params", mainClientHandler.params); + result.success(startParams); + break; + case "started": + sendStartResult(true); + // If the client subscribed to browse children before we + // started, process the pending request. + // TODO: It should be possible to browse children before + // starting. + if (subscribedParentMediaId != null) + AudioService.instance.notifyChildrenChanged(subscribedParentMediaId); + result.success(true); + break; + case "setMediaItem": + Map rawMediaItem = (Map)call.arguments; + MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem); + AudioService.instance.setMetadata(mediaMetadata); + result.success(true); + break; + case "setQueue": + List> rawQueue = (List>)call.arguments; + List queue = raw2queue(rawQueue); + AudioService.instance.setQueue(queue); + result.success(true); + break; + case "setState": + List args = (List)call.arguments; + List> rawControls = (List>)args.get(0); + List rawSystemActions = (List)args.get(1); + AudioProcessingState processingState = AudioProcessingState.values()[(Integer)args.get(2)]; + boolean playing = (Boolean)args.get(3); + long position = getLong(args.get(4)); + long bufferedPosition = getLong(args.get(5)); + float speed = (float)((double)((Double)args.get(6))); + long updateTimeSinceEpoch = args.get(7) == null ? System.currentTimeMillis() : getLong(args.get(7)); + List compactActionIndexList = (List)args.get(8); + int repeatMode = (Integer)args.get(9); + int shuffleMode = (Integer)args.get(10); + + // On the flutter side, we represent the update time relative to the epoch. + // On the native side, we must represent the update time relative to the boot time. + long updateTimeSinceBoot = updateTimeSinceEpoch - bootTime; + + List actions = new ArrayList(); + int actionBits = 0; + for (Map rawControl : rawControls) { + String resource = (String)rawControl.get("androidIcon"); + int actionCode = 1 << ((Integer)rawControl.get("action")); + actionBits |= actionCode; + actions.add(AudioService.instance.action(resource, (String)rawControl.get("label"), actionCode)); + } + for (Integer rawSystemAction : rawSystemActions) { + int actionCode = 1 << rawSystemAction; + actionBits |= actionCode; + } + int[] compactActionIndices = null; + if (compactActionIndexList != null) { + compactActionIndices = new int[Math.min(AudioService.MAX_COMPACT_ACTIONS, compactActionIndexList.size())]; + for (int i = 0; i < compactActionIndices.length; i++) + compactActionIndices[i] = (Integer)compactActionIndexList.get(i); + } + AudioService.instance.setState(actions, actionBits, compactActionIndices, processingState, playing, position, bufferedPosition, speed, updateTimeSinceBoot, repeatMode, shuffleMode); + result.success(true); + break; + case "stopped": + clear(); + result.success(true); + sendStopResult(true); + break; + case "notifyChildrenChanged": + String parentMediaId = (String)call.arguments; + AudioService.instance.notifyChildrenChanged(parentMediaId); + result.success(true); + break; + case "androidForceEnableMediaButtons": + // Just play a short amount of silence. This convinces Android + // that we are playing "real" audio so that it will route + // media buttons to us. + // See: https://issuetracker.google.com/issues/65344811 + if (silenceAudioTrack == null) { + silence = new byte[2048]; + silenceAudioTrack = new AudioTrack( + AudioManager.STREAM_MUSIC, + SILENCE_SAMPLE_RATE, + AudioFormat.CHANNEL_CONFIGURATION_MONO, + AudioFormat.ENCODING_PCM_8BIT, + silence.length, + AudioTrack.MODE_STATIC); + silenceAudioTrack.write(silence, 0, silence.length); + } + silenceAudioTrack.reloadStaticData(); + silenceAudioTrack.play(); + result.success(true); + break; + } + } + + public void invokeMethod(String method, Object... args) { + ArrayList list = new ArrayList(Arrays.asList(args)); + channel.invokeMethod(method, list); + } + + public void invokeMethod(final Result result, String method, Object... args) { + ArrayList list = new ArrayList(Arrays.asList(args)); + channel.invokeMethod(method, list, result); + } + + private void clear() { + AudioService.instance.stop(); + if (silenceAudioTrack != null) + silenceAudioTrack.release(); + if (mainClientHandler != null && mainClientHandler.activity != null) { + mainClientHandler.activity.setIntent(new Intent(Intent.ACTION_MAIN)); + } + AudioService.instance.setState(new ArrayList(), 0, new int[]{}, AudioProcessingState.none, false, 0, 0, 0.0f, 0, 0, 0); + for (ClientHandler eachClientHandler : clientHandlers) { + eachClientHandler.invokeMethod("onStopped"); + } + backgroundFlutterEngine.destroy(); + backgroundFlutterEngine = null; + backgroundHandler = null; + } + } + + private static List> mediaItems2raw(List mediaItems) { + List> rawMediaItems = new ArrayList>(); + for (MediaBrowserCompat.MediaItem mediaItem : mediaItems) { + MediaDescriptionCompat description = mediaItem.getDescription(); + MediaMetadataCompat mediaMetadata = AudioService.getMediaMetadata(description.getMediaId()); + rawMediaItems.add(mediaMetadata2raw(mediaMetadata)); + } + return rawMediaItems; + } + + private static List> queue2raw(List queue) { + if (queue == null) return null; + List> rawQueue = new ArrayList>(); + for (MediaSessionCompat.QueueItem queueItem : queue) { + MediaDescriptionCompat description = queueItem.getDescription(); + MediaMetadataCompat mediaMetadata = AudioService.getMediaMetadata(description.getMediaId()); + rawQueue.add(mediaMetadata2raw(mediaMetadata)); + } + return rawQueue; + } + + private static RatingCompat raw2rating(Map raw) { + if (raw == null) return null; + Integer type = (Integer)raw.get("type"); + Object value = raw.get("value"); + if (value != null) { + switch (type) { + case RatingCompat.RATING_3_STARS: + case RatingCompat.RATING_4_STARS: + case RatingCompat.RATING_5_STARS: + return RatingCompat.newStarRating(type, (int)value); + case RatingCompat.RATING_HEART: + return RatingCompat.newHeartRating((boolean)value); + case RatingCompat.RATING_PERCENTAGE: + return RatingCompat.newPercentageRating((float)value); + case RatingCompat.RATING_THUMB_UP_DOWN: + return RatingCompat.newThumbRating((boolean)value); + default: + return RatingCompat.newUnratedRating(type); + } + } else { + return RatingCompat.newUnratedRating(type); + } + } + + private static HashMap rating2raw(RatingCompat rating) { + HashMap raw = new HashMap(); + raw.put("type", rating.getRatingStyle()); + if (rating.isRated()) { + switch (rating.getRatingStyle()) { + case RatingCompat.RATING_3_STARS: + case RatingCompat.RATING_4_STARS: + case RatingCompat.RATING_5_STARS: + raw.put("value", rating.getStarRating()); + break; + case RatingCompat.RATING_HEART: + raw.put("value", rating.hasHeart()); + break; + case RatingCompat.RATING_PERCENTAGE: + raw.put("value", rating.getPercentRating()); + break; + case RatingCompat.RATING_THUMB_UP_DOWN: + raw.put("value", rating.isThumbUp()); + break; + case RatingCompat.RATING_NONE: + raw.put("value", null); + } + } else { + raw.put("value", null); + } + return raw; + } + + private static String metadataToString(MediaMetadataCompat mediaMetadata, String key) { + CharSequence value = mediaMetadata.getText(key); + if (value != null && value.length() > 0) + return value.toString(); + return null; + } + + private static Map mediaMetadata2raw(MediaMetadataCompat mediaMetadata) { + if (mediaMetadata == null) return null; + MediaDescriptionCompat description = mediaMetadata.getDescription(); + Map raw = new HashMap(); + raw.put("id", description.getMediaId()); + raw.put("album", metadataToString(mediaMetadata, MediaMetadataCompat.METADATA_KEY_ALBUM)); + raw.put("title", metadataToString(mediaMetadata, MediaMetadataCompat.METADATA_KEY_TITLE)); + if (description.getIconUri() != null) + raw.put("artUri", description.getIconUri().toString()); + raw.put("artist", metadataToString(mediaMetadata, MediaMetadataCompat.METADATA_KEY_ARTIST)); + raw.put("genre", metadataToString(mediaMetadata, MediaMetadataCompat.METADATA_KEY_GENRE)); + if (mediaMetadata.containsKey(MediaMetadataCompat.METADATA_KEY_DURATION)) + raw.put("duration", mediaMetadata.getLong(MediaMetadataCompat.METADATA_KEY_DURATION)); + raw.put("displayTitle", metadataToString(mediaMetadata, MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE)); + raw.put("displaySubtitle", metadataToString(mediaMetadata, MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE)); + raw.put("displayDescription", metadataToString(mediaMetadata, MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION)); + if (mediaMetadata.containsKey(MediaMetadataCompat.METADATA_KEY_RATING)) { + raw.put("rating", rating2raw(mediaMetadata.getRating(MediaMetadataCompat.METADATA_KEY_RATING))); + } + Map extras = new HashMap<>(); + for (String key : mediaMetadata.keySet()) { + if (key.startsWith("extra_long_")) { + String rawKey = key.substring("extra_long_".length()); + extras.put(rawKey, mediaMetadata.getLong(key)); + } else if (key.startsWith("extra_string_")) { + String rawKey = key.substring("extra_string_".length()); + extras.put(rawKey, mediaMetadata.getString(key)); + } + } + if (extras.size() > 0) { + raw.put("extras", extras); + } + return raw; + } + + private static MediaMetadataCompat createMediaMetadata(Map rawMediaItem) { + return AudioService.createMediaMetadata( + (String)rawMediaItem.get("id"), + (String)rawMediaItem.get("album"), + (String)rawMediaItem.get("title"), + (String)rawMediaItem.get("artist"), + (String)rawMediaItem.get("genre"), + getLong(rawMediaItem.get("duration")), + (String)rawMediaItem.get("artUri"), + (String)rawMediaItem.get("displayTitle"), + (String)rawMediaItem.get("displaySubtitle"), + (String)rawMediaItem.get("displayDescription"), + raw2rating((Map)rawMediaItem.get("rating")), + (Map)rawMediaItem.get("extras") + ); + } + + private static synchronized int generateNextQueueItemId(String mediaId) { + queueMediaIds.add(mediaId); + queueItemIds.put(mediaId, nextQueueItemId); + return nextQueueItemId++; + } + + private static List raw2queue(List> rawQueue) { + List queue = new ArrayList(); + for (Map rawMediaItem : rawQueue) { + MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem); + MediaDescriptionCompat description = mediaMetadata.getDescription(); + queue.add(new MediaSessionCompat.QueueItem(description, generateNextQueueItemId(description.getMediaId()))); + } + return queue; + } + + public static Long getLong(Object o) { + return (o == null || o instanceof Long) ? (Long)o : new Long(((Integer)o).intValue()); + } + + public static Integer getInt(Object o) { + return (o == null || o instanceof Integer) ? (Integer)o : new Integer((int)((Long)o).longValue()); + } +} diff --git a/android/src/main/java/com/ryanheise/audioservice/MediaButtonReceiver.java b/android/src/main/java/com/ryanheise/audioservice/MediaButtonReceiver.java new file mode 100644 index 0000000..02540c0 --- /dev/null +++ b/android/src/main/java/com/ryanheise/audioservice/MediaButtonReceiver.java @@ -0,0 +1,19 @@ +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_NOTIFICATION_DELETE = "com.ryanheise.audioservice.intent.action.ACTION_NOTIFICATION_DELETE"; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent != null + && ACTION_NOTIFICATION_DELETE.equals(intent.getAction()) + && AudioService.instance != null) { + AudioService.instance.handleDeleteNotification(); + return; + } + super.onReceive(context, intent); + } +} diff --git a/android/src/main/java/com/ryanheise/audioservice/MediaControl.java b/android/src/main/java/com/ryanheise/audioservice/MediaControl.java new file mode 100644 index 0000000..dfdfaed --- /dev/null +++ b/android/src/main/java/com/ryanheise/audioservice/MediaControl.java @@ -0,0 +1,7 @@ +package com.ryanheise.audioservice; + +public enum MediaControl { + media, + next, + previous +} diff --git a/android/src/main/java/com/ryanheise/audioservice/Size.java b/android/src/main/java/com/ryanheise/audioservice/Size.java new file mode 100644 index 0000000..2218c30 --- /dev/null +++ b/android/src/main/java/com/ryanheise/audioservice/Size.java @@ -0,0 +1,11 @@ +package com.ryanheise.audioservice; + +public class Size { + public int width; + public int height; + + public Size(int width, int height) { + this.width = width; + this.height = height; + } +} diff --git a/android/src/main/res/drawable-hdpi/audio_service_fast_forward.png b/android/src/main/res/drawable-hdpi/audio_service_fast_forward.png new file mode 100644 index 0000000000000000000000000000000000000000..24d97839de7159e1edb16bc276b6b1443ae72dd2 GIT binary patch literal 561 zcmV-10?z%3P)W^ZwrVjmv!R@6%_V_xU~VZ%U<3I_cye1l?GYv&PDzJSA+! zF>J?zj5QA9D3%og8pcE1#{u+Ys4<4OxQtCjfSzI&Z*Ud;89+7s#5fLOQC?7zZ*jMF z27;2z;1$kcbrB$_;1kYbVIT;q_>Q~Sfv&tD@hkX&+ZYH0CC^vlF*qdcNp1nE|FtjT zqKgAS^(o^z`rCFZ1t`fEjAEUJ^^gLTZ@ddNY8CPU3lNz`VSmB(HHA z1Lz6_!EfBb9xN>aL^SZXus#EbXyBjYq`GeB0sX=~3}yt)iaLD;%kzTX;Q}_x_R41P z0Q=FG=arQ)i6dAcKEi>ZXH5KrgXZV^;z}HPN;`fj&(ZTMAG` zbO1JLcg1M~Dq{kJZECavea3OEmSGA|6;rq<#W$!4s3scR3DK_kdjST3rtk#A0X6DD zWxU5FtW6DR0;=KzZemSfjj^~Z(|ClDG@We)n#AL{P;dLEnu0#!28J?0>WU5F6n0B- z8JnZVNL(ZTzYp}JJ^^#rSS93E*hwdy{HOc@RbRu5bM{co00000NkvXXu0mjf{g?Y@ literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable-hdpi/audio_service_fast_rewind.png b/android/src/main/res/drawable-hdpi/audio_service_fast_rewind.png new file mode 100644 index 0000000000000000000000000000000000000000..3b564bab3a27de4375dceae4df977bf11a54c891 GIT binary patch literal 584 zcmV-O0=NB%P)4EG)=N zNwHv~7X0a$31-Z zPpN^hHr~qXgg-k_to2#E!%vOs2!w9z!Si@4Nu>u$l;|P8#}YNGEf8urjoAhKoqbTJXq$QA0o*E9~WeR_5AmLWVun(_N!ZZM(AM0@* zUsC$#1I3_8oWXmoYuy$ID>04x8U2kw7{C=sVe+>&X5 zYB+}vg@Gc}FgD;KK4k@p8RIyBCxw9$-7z8TsM)MQiGyvy73s_0vF(S2PPv;EDEfP_ z3P+{u)>MP>&n!79Oz6}=(O=1Te^Sa3flnAa0C3ukqs3~Jic$*8&rEJSJp0`-Re11kTHIFd@5iX{;Gb%IuvxwzlejJn-(o;9uoqi#6(8mMpp9Tg_=?OG z0!oBV;254^CWpTl+i(rH3IoN^5$wWrPJcJnVK3^1fnsO{D+*@xVPH{!N|h?rLB9c{ WrM8_K70A^90000|k1|%Oc%$NbBQaoK8Ln2z=-dM=n;2^+ykz1rf z#e}hGm)|@#Z_fwp{Wp&;NSn0P-alXSo8bb+@B;6oScZjA}Sse x6dapaIE7%0U!qqUtaq3)&Jwa_zi53d&el6udeeq*`#7L|44$rjF6*2UngEX3FXR9K literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable-hdpi/audio_service_play_arrow.png b/android/src/main/res/drawable-hdpi/audio_service_play_arrow.png new file mode 100644 index 0000000000000000000000000000000000000000..84998dd616629e6503b3099ec928da901730f28e GIT binary patch literal 352 zcmV-m0iXVfP)(^eunGR z8C-RbbI*B(`~L*L-lv}4_f3+11;QFuF;KT9Ns=VNu6EZLsoRqW!7KJ6fgr^d=A(e} zuzie10cAa7G7<>hafab2plpOqY(@d)yTEiL5PV_-gHb@V$9RXCD4?tsj+Aj)C4sW; zu^0)Ib%LQtpcGG7iUP{R&ULftD*!bxRW~TrzRIjb8QCeuy2kVX)WmWjNXei#91E}* zQ#@ey-;feOU)WJ}0hSDEV6Ie10Z@uZZ1)7xCFlc}f?Q!=RQV+O1C-(r}*qI}2n%k>T4iq=J!?}!Qj00000fhdEP)_!ow0zD)?)+JaEQrR zKuOP7Ku;_nsA3-@v4E29F(c!H4+(+?Y+*1K5Y%ynBD!J#CEa1ISyVegNkI@l3g}wORWuLK35MGRX&RJQ=t80K`x8u> ZJ^@iggd_0q#k&9i002ovPDHLkV1g)TmNftX literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable-hdpi/audio_service_skip_previous.png b/android/src/main/res/drawable-hdpi/audio_service_skip_previous.png new file mode 100644 index 0000000000000000000000000000000000000000..7dd69775a3fbd0d8696a94034d975aeee396b777 GIT binary patch literal 410 zcmV;L0cHM)P)mh90zbbcm@iCfhI9py<68%=vm+7XymDKY~-g>&DrEqB&hy!BY^RXkG&*aVA-{Qa?I+u^S91R;_up ze{y6CilGK@90Vw~UI$ij69gz)vxHGx1_6q#-GyyGw};%XL1j$gHV9BGX9#|k1|%Oc%$NbB96VhdLn2z=o?9r$pdfHyL-`wf z!=3<{jwkE(8OPjuuj_m;z%YbI)cGg|^-n%Ze|EeqC(p)@hrXYi1~iMo)78&qol`;+ E0M;)i4*&oF literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable-mdpi/audio_service_fast_forward.png b/android/src/main/res/drawable-mdpi/audio_service_fast_forward.png new file mode 100644 index 0000000000000000000000000000000000000000..f81de6fad1367a43ee797696c4f402655d0eada8 GIT binary patch literal 241 zcmV@K{YvGR^lf%EG&Te&2sF zHKo#jKQXkwVbjqPV>F7i>1cyH_GlJmpQB`FOi=#RW<|-?=my%&E1MVHal-Ijn-?X! zVvbs@4Ly+Th<=QXJ#fY##)claVS#$A4f${vjNY|*(H6ZxyL)A`q6?;|MA&NP<4@5+ r&d)5yrlS>RXccMGQRf@>zu(vcz$5JSGYTh300000NkvXXu0mjfiZjc*z0~<>lAz%(*>k(}2Ol6}75YpsHGn=2ykj%ht zA>^x{cfXmP5RUV&23Kv^fHT}<(xk0|bG&1Lx29}B2P1r2Pcka8RnW(?-Pwtveaf*_ zFvKigb8l2)1A2JCUjlDMCARwb0H1sCDk`y?3vTh1xggutf6*x(F+UWQ*wiE6;YZ#h zXWROc8%%SeXkUsJ#ITDoeh)=B$+fBY5+6BHiLHkh@r18U+EjeqhCNQZvjnTj?6ZLR RhKv9J002ovPDHLkV1n9tao+#{ literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable-mdpi/audio_service_pause.png b/android/src/main/res/drawable-mdpi/audio_service_pause.png new file mode 100644 index 0000000000000000000000000000000000000000..e2f63777288fa6c24c4420d70ddb26c1be00234c GIT binary patch literal 170 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gjg`O^sAs(G?&o1OWlpxZ2(Jz4K zpaRDdPVW!{-mYD2t_G$aZm(Ek8BdEX|Mx!F*J1DMnD45~SN$`UDLB5mqCNM*^&M<~ znU`~9eB5}arTD^oL$<%-(^-T(6dHjZ5X$S|02-?~TB3fI6+PVf8C#ROCx{7G(9>gtZmA1ABehP;MY0>`2 zL4FVsGyRXf7w|qG=R2H2VVOY{6|6-8mv~1F+fe{?(Zo>{kR`C+enc+JZpGa9pJb;04E6&2vOQ zc*8Bqzs~=UK#E743Ru$*Tw!D8;u(RkQ_;s0E==2yIe`>+*jcD}0`QGzoa9nG1|(?Y j_UbWU7yITp#s8H7DH2yDbI(FB00000NkvXXu0mjf6Blz& literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable-mdpi/audio_service_skip_next.png b/android/src/main/res/drawable-mdpi/audio_service_skip_next.png new file mode 100644 index 0000000000000000000000000000000000000000..13663f156e035c474b5b89f8e947a811627bf855 GIT binary patch literal 344 zcmV-e0jK_nP)41c>vDLrkM51OV^2z*f z&P{I!fT%auSix`zAkGW+Vk87m!2w1>0Jm62zSF;`iZjfiztc^wU>|uc?x`%m2kx=l zYW}kTbv)q&eG`3u6;BJi;S}?}oz|>C4G-AC1iF(JPXZKig#`?yDV!9T q5k1<8U$7QH0oy1y)@r-8Qson=;-GHiIFmX60000scURUn6hGXLYq*az~Rs*|C-01RBEN>FVdQ I&MBb@01`SQ=k|L)fcLx4Ij7FIuP>2EBoc{U0CO1oFWzRMH9TPp{jqyng)&Yt6tg!k)W8*1 zWA)~RGTyO+k=VTLhTs#In2yQ&mr%z2&r=(W!P`kF;{=mcd%Fq2Emo@Zb{pdR8$Q5j z72YC38FyIJ;wKf#TVx1cuv@0L=upNP=F9Mg8UP>I2<;6yfbT!;F{VO!Ll3B_ zkOLan#z07Kr~wyPD#Kg!fHxeL=`Aw!fMqRSZ76S1p(8m5SSW9|p+~G&>Fp*|!)(>w zPD0Pv$8ZeZzl0jNk#~!f>}@wx#~#LG^X7#bxW;Cz-n`H`CSvxs3cdR6-iXNCEVQ86 jnHs4#kw_#GiJssK_sz@215RqW00000NkvXXu0mjf(D<#v literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable-xhdpi/audio_service_fast_rewind.png b/android/src/main/res/drawable-xhdpi/audio_service_fast_rewind.png new file mode 100644 index 0000000000000000000000000000000000000000..52c5e7c3dc18a3c243de87f5305368ac92550eac GIT binary patch literal 460 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezr3(Fi!S#aSX|5e0zPPpGcrYTcUzW zz?#-c8B0~BsU352%H>#Uo{*U8>gb=khc$XHlQ6HXuBbUf$&n@rDGh^*5>Gc42)Es| zZJ&Gd^YNUbecToEZ70t>`Q}bcEEwEi+Sf%5}ktJn6{8 zjQ^&zEZXIpAemo~S+aK_P;6&^?H{@3FKr;v!iv&;Y7KD#A=`V`JYZgz=V;{^c-QO` z&v9Xfxj_BG5)amXvAxON^~$WV(&kvAy8^ds>2b%~7VkTs@CjVXWm^3sWfOZ=lE$jr z|31$CVXA7tN@0F?4CEvS2=L>^=Feu)=)Sq!o;plhLS zp>Urb*Yms#&-1;xHxI}8^?kl|-+#gTeZJ>=zUO>Smd!E5U<3Gt+H2)b8c!M3W-Lot!901>O2^&)lfFF8~++$dlVnCpU z`&gA~K;SJ7H5yWJ08HW@cBL8s9ZcY&y7xv+0f8)4!X0Q|%&98NVL z(7{b?!om~-=@s9`ve c<``mr0q*?+C1pHRbpQYW07*qoM6N<$f>34ZivR!s literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable-xhdpi/audio_service_skip_next.png b/android/src/main/res/drawable-xhdpi/audio_service_skip_next.png new file mode 100644 index 0000000000000000000000000000000000000000..f8662731e4ba9eaba8ff8dcd7dcebcf7b2924f39 GIT binary patch literal 602 zcmV-g0;TJ|x8?;AECPz1QHU1WXs}TfEG*SRtZZyVL9nq>6iX3{V4=N*2x6m+-~&rV zu@S}h0~9sjy11L&pT%`xk=b|ZX(1}38G;U&72KiGF zfC^sydmBSB3M3S82b*FQAgqmdID~~U3MBl*d92mN1>Q{on87XV!15Rc5?vv; zMO?=oEQ(QpP*Tru3@c+4Abc~v;wr{t6d<(P5}shU4%%&20<{!y3M*n2NVttj48$r> z!3!M4nivHLYvY?RFm2Psz?3(Egd!f{usO!pyb2IX>LV_hW$Ke-frKI+V=`8OGCp8$ zYywq$$2E+_Rzf8_$4Lw}3ZGYjY1|NYt#i&0c@n4!wSHPSG;3;#%agzZ?8I<0!Sf{W z2N$tQ8?$rP0`GAEi(3tzr9c(Ggwwpu=x;ZC76KK#!5Opk_Ph#|aTf=%G>!1R6!?U5 zSf5t#UI|oiU-*yEQhUsUKmnJrQ3qWn3xO)W-~@(r*kyVxP!{&?+q)UM8v+%)#RVyy zxb;%tH|}A-E|*K%0#$s*6t?KW&y0GsD=>qnID)Y(o_tN7mIVfdfn%#Sj|0=B9f5v~ oX8F`>!3In;xKi7)%+o1<0Z~bPa3FcXN&o-=07*qoM6N<$f^F9PDF6Tf literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable-xhdpi/audio_service_skip_previous.png b/android/src/main/res/drawable-xhdpi/audio_service_skip_previous.png new file mode 100644 index 0000000000000000000000000000000000000000..f5a741e9a08a279d160e5d0b849909e0a65db0c1 GIT binary patch literal 639 zcmV-_0)YLAP)W3k6Xj;sfLBXK_a|hGFAoXLjyD4otO|d;kC0d(Pf-w^mz3*;GP5@0z;v!xv=FJx)kihT9U5l^nokV^D zScx-uskkjOKk^a4Cfvi9!jb=t+yr(B$KR}`qsYj03yt6s-YPb6(~)};_X%g5hr+Ev z(NRblZDA*_;=L-B(bODuj0)Y=b92`q)euO{wE{bEvyuX-Z(|sz@wIXSsd*dNEo`ak z38d!VC?xod?sKEGmU0+Jg+{)cV@Ih?eG7YVOJ`RpaR{W=8^uXXS5hEZa{yDgt+Oo^ zhd{D!Q%LZ$+F$6#DUe!!xzLJEtJ39e2FIk(W3)RI#w(Dp4B`}C$j*(p1yW2?Lb?4^ zRe`$jkIgx}GM5|G5J<6&-~b*7m%`N&Ncj4(8J97hHK2isHcztz?)i*$tU{}7mZkU` Ze*k)W@{xV#vhe@_002ovPDHLkV1g^O98&-Q literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable-xhdpi/audio_service_stop.png b/android/src/main/res/drawable-xhdpi/audio_service_stop.png new file mode 100644 index 0000000000000000000000000000000000000000..f7d0bc903efd8269da2202637e54add14ff576a4 GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUtcuyC{kc`H+=Qr{)CDwRs5Qe7Ud zU`ekV8iq+c!#2$8RYSvssu;m(3}9}r85-u_zTq0SV_`2DW{XpM-Jjz)R-wNa49(!` zea2;M#hkJmTEW2t9^okFm)X!Nj<9%ss6ENGWi_;etM?NRv0uh(LJWp1aD>Hk7-z7& zOops*gx7r>_pqfbhAeT@dyhlfTs&hibP7k<*j>aLEq;n)Fmw_J6+FNmEb2x>r*ZW@ z3A^8>KE*K@^1{I%+{P{}=oUksxOy*zC%L*C3`O9o_=2n0X@=vA7z{-WsNN4e!4VBM zb}<-=91vmm`w>If6r-W&0TEvJ?|6*erX8)uV2EKr^`0#d8yqlge-eYC=mAym6}N=r)Be~DMGj~JuW=G9V>J{ppsMgI`wsS*VKI!s zP{e?SaSj{0!H_3z3iojkOS{F87j6vaF{p_{^%x9!;9lS`R&=AGlek~Fj?G%UnGu7b zQ@9bFzzS`=&M_FW#!cZVc9g}C6|RC&T*5$^3|ZiQ;}wp{_;OPWhIVnIxQW5C8d}9o z3J2e(u(-^IR&ZmuA^dex)CH>C49(ywcqg2;tDwRrgYkvXW+)z4> S9mjS60000b3-VL8tNH=rS10n9U+o2|rSAU69RvWhvf zoh`xkagf23gJKERa%L^BB!Q-LmcrMV>iLT|0>gRNL$;RbwZ9A8KYnz*&D@o@qIr(mn=|?^Vs3nWt$gl9 zo7RI38>U;exc|Fj^ZTb{L5Xwfj9;(4?um$%8QQ7z=e?MpK&{$JI%ga5^wbPuWjGV co6_I8e}9VPV_@0z2^c60p00i_>zopr0Mo)?oB#j- literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable-xxhdpi/audio_service_play_arrow.png b/android/src/main/res/drawable-xxhdpi/audio_service_play_arrow.png new file mode 100644 index 0000000000000000000000000000000000000000..4c3fc47402e90284ac7dee072b268870d8fdbe48 GIT binary patch literal 720 zcmV;>0x$iEP)5eWX`Id(?R8e#^4 zGJkN@eESk_BT!}ty;u-EdnslSD7%^-VOjM2g_~ud%mUqE8u5ccV9L(@$Jh=J|l=joa`7 z`$`MbC4p-Aj!UJ7>5@Qiu{kVGg#(S?l=OE;L$|Rhy32pjH73;X9tWd))Cmrgxvr|m zj)TLzL2q<}rh>wJ#wF>&VM8ylEqd=6D>2M39M%n2%~U1Dxr?pQyUt3%VbTGrY5Xw$ zYSq%{{W?}aoDaJ7G+84#hN%MHG^0lJ%8b5 z5$7WgNv}iI`GXs(bhxPuGYC_|E9{P*HAIUzW4IVSW2h!^o|>!gfVHDRm=T;b7v9v# z!;~<;aUbiVXQ~vF59ba=&r(UaiPh0_BuSFY7ykg94!5gId~Esv0000y465xJ@sMw<%ST_Op@Z752+c7Z6gXxAock8Bv1Zf1JbXLD@PXQc%qm9Kqv38P`P)K~!Jh^LQdC z=bFhSsAdr#;-R3t$>bIUH*gA%1!Yd61VJ@_;2k^|l)V)t34)tA7L>ger3s>%GyQ_s zf*dqbqM(|q*dL>zF;lUEsHOwoV`q?$du1wGP|XeO3vzOIrnY1i1lMo^JA&NQnc9+F zP|aU>Cdki33W9%e7@G${&78q=gP{8}!g+kGr>7~QmLdh!tl%p>?5HHvP_&?$-|)sD zXgr_c(LvDtSysKzy)p=D<}%(hZpU+m1yMas-{R>(P%{_smc#|Qgz+a{5Y>*#m)JfC zYUV8VXt5=pFy>SVf{XYB8wWx6=0DZ)N#mAN!gy09XaR3((Vmkq=2Qq;!f8A^PRuL{ zx{i+|zR)I&Ke2*V@iSgZ5i=`-uBm>hTF-lCMbIjK$J^G~{+Dpii4ydUHNJaIyPyTk zbs=Wj1dZ?`p3-qgZ9)yj3c9Gn6~t*1bRIA3u@5_;mZAhL;R|f*OUxt-TEt#KooESl zWf!!J@31RE%pIB9l2y<$4$9CCn^0pGL96&l#^Y5b3mV~bnY&&~6?6{o203V?L_t3U zWp71Eg8spQu{Q~(1VJmRrHL1UGAEH+&=NkyBSCqS$t7r{YR1gg|J;@wf|gX0HR)Rm zeF$1m;L*yp(zT$YCg7-d3b%u|xlWm~zXoarwzj-xPtL$VUTy)a5ja~eg7swp0000< KMNUMnLSTXsYL|^5KiLW9{IFa5T3;)+aIY;5Ek$buI9+6U4n$#<`a8AQtcIl zSMkdbxwJzN=5Q7_hm_N7L72kfA>(hApcA;|{@SM9f^ZP$@$VR7nkfh`Ip&*Yl@jFj7zly;VFEI z+Yyo-g=z@GELN5G`p(Pb1qnL~>%!7R)tHM7;wFpTSN%?~C zBre9tCIaOO!khRtLL#v!UC_JuGe#znC|%Grp2K$$l8M2@2I)n7gufyrRU{LA&CdwC zow1fni=YyP;D=2NdUk>~iA^AL1TTt45566WmO1Ee~AdUmd*(}UbA3

bHXl1 zS@XGi2}L(}@SO3UM%%$4;3QA*}#u5A-K@N2nl!@_ae608iycvQr zu^uZ}QnLi5@xFww6jL)bPf#D>Q_^uO@J0)o#cR0S2^s(%#g|4Ko0=~uNw&&?HN#in z?GV&QI2rq``~!Hq1SLt5U`g2azR?L9fKU!xG5DZXdj%!Q)_m@?y&t)D3+jW{?S9gv z6O<%dODHS&%=Y546O=_LF8kkqbibz9jkGtd3I{B52|AbFnCDt5DUayjLE*U0zQ4!m jVLs1QQtTZa9nIx`rGVOR_Q_RH00000NkvXXu0mjfwRD1B literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable-xxhdpi/audio_service_stop.png b/android/src/main/res/drawable-xxhdpi/audio_service_stop.png new file mode 100644 index 0000000000000000000000000000000000000000..7f8775aea29b98810ead442e6acfa8e6e40a8a43 GIT binary patch literal 252 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!hdo^!Ln`LHy|6KtiIIWD(P%-{ zbO+uL2hl6VDt?KaG7-NdUvBFDR>JhdecRuhYMGMlxzD-2+$gl&9?M$gzd#{CK!Zbs zg^P)`vB|-a=G=W;CBJRu3cu%0=PJ2ZX8XeKIOD%t+#DW)Ut}H@E(Cgq!PC{xWt~$( F69Cf6NJanv literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable-xxxhdpi/audio_service_fast_forward.png b/android/src/main/res/drawable-xxxhdpi/audio_service_fast_forward.png new file mode 100644 index 0000000000000000000000000000000000000000..6153646aed29c4cf803ea3685f2bee38370afad5 GIT binary patch literal 838 zcmV-M1G)T(P)(b~W2>U|7U55fa25~O?kS1ZTeLB#a~;oOSM9FN zOzAz`a&OVZAh?Qmac}LmX{PjEYq_^*${-t;_*ZzMcHg9Ey+xZ0y7dx2+0rE*t+%K! zsPicf)^1%{iPu}S#X;~Jj$%*tF3=2v;3m%Dj_h5K83%Q~$7=R2z%~bU-q7>y-<-W~ z_uX4G=b&|bqsL!((Rz#K9Mt&>ujt|SFIsP3gF0VhfA-cJ1V7?f_SPE&H}DP~8hUT7 zL7iXl*pPc`4`Tdu^-bJ8#NHMLbw0zR=DhQW*4xq`_yaEtskgO3jGx_qhyz3DZE;ZN zI##iJnR{Cv1b^eS8J<=~>ur5d=RBTT#@;A{7(Xa`4SScVH_`#XzxdK^{umOhH`)Po ze#P^+D|&C#1K|JLzdIxMMm~VfcQ_KYcNq@gGG4NA|1DT=^aD6=;)RJ|y-^R~3SPxM z(R-sEz&bACnPusXb^xapz5ydxZ0e&^kV{10MsA z)>~uH?{@H;iqU%e9(2i0-L3`g?Q75)-ZJkC>Lu*m=|Lmo7Z6Wp?>5aj=zTmGsP|0Y zy+zv`^fQj@^L8z0Z_$i{M#i6m*_XWwGQ*&&cvV6OLcaSW-L^Y+g9*J;5r2R>Fl zV}Ci*r>E7VIVa%8q7+51uq0#cNo%I8U88aKoZk%}v&|c&j%sY&=%}+qqgBPVZduEx zs(hs*bUzf-*be)arf`>&P%S>J!RJzxCZ_W7Oa?{7ROsbHX##s^AkqN=Y5o!!py z?wEMQ_Og|Vv-0(Ku$+JBFWb%6^(>mj{DJ%b^o@=eYpM_awCIzcbNW%!qMu?1w^oSO z=L>J%u^?lgYXOh_*A^W;4WLeorr;mh3Tc91^>ufe&TBi(Vt4(l+ax;gV7_wFELISE z_J?yPCZvItuvVnBD@yZ&ESmnv_m7~W#;o@NXKH^`O;?oW28wc7woF&&nrRCbmi^)J zpQkVosL@+d{4wVrtHKjHKu&v--r?oOtv+%hXSa(KG4wzBJu_L2^U~Rj!$0_r${g*M z=9sDSqUSw#AW&uA@$hB)3(g9Gl`8sK?0&n=(+%Xn)-?sj`*H=0tbo4oy-;{qTA-T~ zWdBaN^*7(_+}wXq;ULgf_74j_Ws}H{m z#O<O-?@4G8Y9P3ptSj;yVtgfu2KGVjpxALXYn&;HI=&D zzvXDra(QhpdxZPfwX!XrKM3ptnQ$%p!2W&rO}@QKKE8e7^@q;6*ZjS{+--}~osj=l zLFUNhX{FNxXLYoGk~J3C{;>I)YWj-EE{>_GjfcONgJ^u9ji{?x4OPFuM9A zI~G^C&MtoIXO`mhvyZcy!QLI@JK-&t_Rjbg*0SLLk9~p%%YAM*+GHE=U#BVO9qImQ zZEw`ed5h;)v)OtEnA|r^oc_3Scm0}bWnhROZ@JsA&surIbXI=u710WPFQ9^3D{3pw zl!)(%Y+m#;tU0^n_qN@CyYpX(E)Cch`oLgb7bwW3?*2NX9td<$*uvk3PGpv@|7{M6 z`qp>0cao3!wnf#gQkar&x+~rCepvazY*2i1-Mw_+aohh#-5fx5ym?1|Lv%;S+GhTq zyXGh`Ijv=?E?i#xy+m9RWI;^p^f`MU=z?O=v?AiW=LrFzbJ;K6Esg2iyEZnf8bg=d#Wzp$P!7Mar`P literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable-xxxhdpi/audio_service_pause.png b/android/src/main/res/drawable-xxxhdpi/audio_service_pause.png new file mode 100644 index 0000000000000000000000000000000000000000..1d0ff1b0f70283312eee2deb92f4006c98f17f6e GIT binary patch literal 461 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7Ro>V4ULV;uumf=j}yBKe+?}*TAr# z#-khAbT%}rRS3=6ur2<^=U3dU`S=+pI5Rw|WRPvR z0d%y34gUgW0ci#v#*U$C*!Smi_IH2Z2nv+>PkHy7&EIeLe2>n(&$m8L=jA^wowwcG eW73lU7wlI~)N9G_N&W|nCI(MeKbLh*2~7aR{icQh literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable-xxxhdpi/audio_service_play_arrow.png b/android/src/main/res/drawable-xxxhdpi/audio_service_play_arrow.png new file mode 100644 index 0000000000000000000000000000000000000000..6fedb6fab36596d6a1380da62b58e79862be51a4 GIT binary patch literal 1119 zcmV-l1fctgP)5G=t{j^yhvW*S9FkKh#f5S)xFW8Y3oe9QIK+iw znA#bJ6d^I%TeHocJ^Ot=F4jEbZM0{gA+E*I(ebv90R)u%j!ifrI_6ff1OX*8xGp;G*02l# zq(yoY&X*VI;aH{s(%LYI2XIXEI2y|k0!rraKGsB!rICz4K*>(ri#77JG&my^0K4!m zu8$s9V;HdjQaNKv-m(tH7zFf-*do2a<+{>Bj8Qs$42LB zEU3SLk}q(sw!OehqH_ZJ z`9r$PdQuh;$8Nlb8$=!c=(d28Kk>YXE0nSTn8wQ@8clRtz#Kjjc^gA_1?<4XSQnD` zjtVHGr+zCg3{8BAZVA|qM{!hi-uIy!0{Tl4^{S9+w}2jgkoNhx7*`<$e#J}beuF|g z1$-=(pbv}A@1kgzfO$NrZu9(s&<+86r2ga$(fM35nlE5G?#3z6xm*^SE1-wBafZ6? za0Ws11k7O@?$V^WejzkVz${)+m(#3Kpg97*#wFU;L#F-$a)U+?v(kOftFR(EPh&$p z1x(|1UHHn1`Uohb%e8NbJ|w9U^%3wD?o_8*eHDloFpFogPMh0N7ebtXJ@^h+N9Q1v z009Mlw)d%z9)CC$3n--Ki48b3dhE5JA^|;VYk6Pvcxy)m0(Rj|T&BrY%W*MA0fn@+ z%%8115MvNf`0q<6&FtN+k`W7-mF`;J96heaFhT({cm$`(bN%3qKtLh2{511SHku&> z^zc0%*VbBn6)aOg`90|M@^mvC%MdVwo8&jy(Xa#oJMgOLuEMr4fPfx8+xyh7ijKE+ zEFxeUPvAItt{)Ez2*^M4tcv{v6!=!^AkSOND(DkX;0>IqZeJ8>Gtw{3w_#&+ES&@^ laY3HzGnq^#lgTuae*s}{Vb9VVMSB1M002ovPDHLkV1h5N1lRxo literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable-xxxhdpi/audio_service_skip_next.png b/android/src/main/res/drawable-xxxhdpi/audio_service_skip_next.png new file mode 100644 index 0000000000000000000000000000000000000000..464a3cca718eabe8a2b4d1e9a2ee1b39bb4a7b61 GIT binary patch literal 1243 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 Lu6{1-oD!M + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/audio_service_android.iml b/audio_service_android.iml new file mode 100644 index 0000000..ac5d744 --- /dev/null +++ b/audio_service_android.iml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/darwin/Classes/AudioServicePlugin.m b/darwin/Classes/AudioServicePlugin.m new file mode 100644 index 0000000..48e8463 --- /dev/null +++ b/darwin/Classes/AudioServicePlugin.m @@ -0,0 +1,617 @@ +#import "AudioServicePlugin.h" +#import +#import + +// 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! + +@implementation AudioServicePlugin + +static FlutterMethodChannel *channel = nil; +static FlutterMethodChannel *backgroundChannel = nil; +static BOOL _running = NO; +static FlutterResult startResult = nil; +static MPRemoteCommandCenter *commandCenter = nil; +static NSArray *queue = nil; +static NSMutableDictionary *mediaItem = nil; +static long actionBits; +static NSArray *commands; +static BOOL _controlsUpdated = NO; +static enum AudioProcessingState processingState = none; +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 NSMutableDictionary *params = nil; +static MPMediaItemArtwork* artwork = nil; + ++ (void)registerWithRegistrar:(NSObject*)registrar { + @synchronized(self) { + // TODO: Need a reliable way to detect whether this is the client + // or background. + // TODO: Handle multiple clients. + // As no separate isolate is used on macOS, add both handlers to the one registrar. +#if TARGET_OS_IPHONE + if (channel == nil) { +#endif + AudioServicePlugin *instance = [[AudioServicePlugin alloc] init:registrar]; + channel = [FlutterMethodChannel + methodChannelWithName:@"ryanheise.com/audioService" + binaryMessenger:[registrar messenger]]; + [registrar addMethodCallDelegate:instance channel:channel]; +#if TARGET_OS_IPHONE + } else { + AudioServicePlugin *instance = [[AudioServicePlugin alloc] init:registrar]; +#endif + backgroundChannel = [FlutterMethodChannel + methodChannelWithName:@"ryanheise.com/audioServiceBackground" + binaryMessenger:[registrar messenger]]; + [registrar addMethodCallDelegate:instance channel:backgroundChannel]; +#if TARGET_OS_IPHONE + } +#endif + } +} + +- (instancetype)init:(NSObject *)registrar { + self = [super init]; + NSAssert(self, @"super init cannot be nil"); + return self; +} + +- (void)broadcastPlaybackState { + [channel invokeMethod:@"onPlaybackStateChanged" arguments:@[ + // processingState + @(processingState), + // playing + @(playing), + // actions + @(actionBits), + // position + position, + // bufferedPosition + bufferedPosition, + // playback speed + speed, + // update time since epoch + updateTime, + // repeat mode + repeatMode, + // shuffle mode + shuffleMode, + ]]; +} + +- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + // TODO: + // - Restructure this so that we have a separate method call delegate + // for the client instance and the background instance so that methods + // can't be called on the wrong instance. + if ([@"connect" isEqualToString:call.method]) { + long long msSinceEpoch = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); + if (position == nil) { + position = @(0); + bufferedPosition = @(0); + updateTime = [NSNumber numberWithLongLong: msSinceEpoch]; + speed = [NSNumber numberWithDouble: 1.0]; + repeatMode = @(0); + shuffleMode = @(0); + } + // Notify client of state on subscribing. + [self broadcastPlaybackState]; + [channel invokeMethod:@"onMediaChanged" arguments:@[mediaItem ? mediaItem : [NSNull null]]]; + [channel invokeMethod:@"onQueueChanged" arguments:@[queue ? queue : [NSNull null]]]; + + result(nil); + } else if ([@"disconnect" isEqualToString:call.method]) { + result(nil); + } else if ([@"start" isEqualToString:call.method]) { + if (_running) { + result(@NO); + return; + } + _running = YES; + // The result will be sent after the background task actually starts. + // See the "ready" case below. + startResult = result; + +#if TARGET_OS_IPHONE + [AVAudioSession sharedInstance]; +#endif + + // Set callbacks on MPRemoteCommandCenter + fastForwardInterval = [call.arguments objectForKey:@"fastForwardInterval"]; + rewindInterval = [call.arguments objectForKey:@"rewindInterval"]; + commandCenter = [MPRemoteCommandCenter sharedCommandCenter]; + commands = @[ + commandCenter.stopCommand, + commandCenter.pauseCommand, + commandCenter.playCommand, + commandCenter.skipBackwardCommand, + commandCenter.previousTrackCommand, + commandCenter.nextTrackCommand, + commandCenter.skipForwardCommand, + [NSNull null], + commandCenter.changePlaybackPositionCommand, + 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 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]; + [self updateControls]; + + // Params + params = [call.arguments objectForKey:@"params"]; + +#if TARGET_OS_OSX + // No isolate can be used for macOS until https://github.com/flutter/flutter/issues/65222 is resolved. + // We send a result here, and then the Dart code continues in the main isolate. + result(@YES); +#endif + } else if ([@"ready" isEqualToString:call.method]) { + NSMutableDictionary *startParams = [NSMutableDictionary new]; + startParams[@"fastForwardInterval"] = fastForwardInterval; + startParams[@"rewindInterval"] = rewindInterval; + startParams[@"params"] = params; + result(startParams); + } else if ([@"started" isEqualToString:call.method]) { +#if TARGET_OS_IPHONE + if (startResult) { + startResult(@YES); + startResult = nil; + } +#endif + result(@YES); + } else if ([@"stopped" isEqualToString:call.method]) { + _running = NO; + [channel invokeMethod:@"onStopped" arguments:nil]; + [commandCenter.changePlaybackRateCommand setEnabled:NO]; + [commandCenter.togglePlayPauseCommand setEnabled:NO]; + [commandCenter.togglePlayPauseCommand removeTarget:nil]; + [MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = nil; + processingState = none; + playing = NO; + position = nil; + bufferedPosition = nil; + updateTime = nil; + speed = nil; + artwork = nil; + mediaItem = nil; + repeatMode = @(0); + shuffleMode = @(0); + actionBits = 0; + [self updateControls]; + _controlsUpdated = NO; + queue = nil; + startResult = nil; + fastForwardInterval = nil; + rewindInterval = nil; + params = nil; + commandCenter = nil; + result(@YES); + } else if ([@"isRunning" isEqualToString:call.method]) { + if (_running) { + result(@YES); + } else { + result(@NO); + } + } else if ([@"setBrowseMediaParent" isEqualToString:call.method]) { + result(@YES); + } else if ([@"addQueueItem" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onAddQueueItem" arguments:@[call.arguments] result: result]; + } else if ([@"addQueueItemAt" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onAddQueueItemAt" arguments:call.arguments result: result]; + } else if ([@"removeQueueItem" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onRemoveQueueItem" arguments:@[call.arguments] result: result]; + } else if ([@"updateQueue" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onUpdateQueue" arguments:@[call.arguments] result: result]; + } else if ([@"updateMediaItem" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onUpdateMediaItem" arguments:@[call.arguments] result: result]; + } else if ([@"click" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onClick" arguments:@[call.arguments] result: result]; + } else if ([@"prepare" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPrepare" arguments:nil result: result]; + } else if ([@"prepareFromMediaId" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPrepareFromMediaId" arguments:@[call.arguments] result: result]; + } else if ([@"play" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPlay" arguments:nil result: result]; + } else if ([@"playFromMediaId" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPlayFromMediaId" arguments:@[call.arguments] result: result]; + } else if ([@"playMediaItem" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPlayMediaItem" arguments:@[call.arguments] result: result]; + } else if ([@"skipToQueueItem" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSkipToQueueItem" arguments:@[call.arguments] result: result]; + } else if ([@"pause" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPause" arguments:nil result: result]; + } else if ([@"stop" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onStop" arguments:nil result: result]; + } else if ([@"seekTo" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSeekTo" arguments:@[call.arguments] result: result]; + } else if ([@"skipToNext" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSkipToNext" arguments:nil result: result]; + } else if ([@"skipToPrevious" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSkipToPrevious" arguments:nil result: result]; + } else if ([@"fastForward" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onFastForward" arguments:nil result: result]; + } else if ([@"rewind" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onRewind" arguments:nil result: result]; + } else if ([@"setRepeatMode" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSetRepeatMode" arguments:@[call.arguments] result: result]; + } else if ([@"setShuffleMode" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSetShuffleMode" arguments:@[call.arguments] result: result]; + } else if ([@"setRating" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSetRating" arguments:@[call.arguments[@"rating"], call.arguments[@"extras"]] result: result]; + } else if ([@"setSpeed" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSetSpeed" arguments:@[call.arguments] result: result]; + } else if ([@"seekForward" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSeekForward" arguments:@[call.arguments] result: result]; + } else if ([@"seekBackward" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSeekBackward" arguments:@[call.arguments] result: result]; + } else if ([@"setState" isEqualToString:call.method]) { + long long msSinceEpoch; + if (call.arguments[7] != [NSNull null]) { + msSinceEpoch = [call.arguments[7] longLongValue]; + } else { + msSinceEpoch = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); + } + actionBits = 0; + NSArray *controlsArray = call.arguments[0]; + 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 = call.arguments[1]; + for (int i = 0; i < systemActionsArray.count; i++) { + NSNumber *actionIndex = (NSNumber *)systemActionsArray[i]; + int actionCode = 1 << [actionIndex intValue]; + actionBits |= actionCode; + } + processingState = [call.arguments[2] intValue]; + playing = [call.arguments[3] boolValue]; + position = call.arguments[4]; + bufferedPosition = call.arguments[5]; + speed = call.arguments[6]; + repeatMode = call.arguments[9]; + shuffleMode = call.arguments[10]; + updateTime = [NSNumber numberWithLongLong: msSinceEpoch]; + [self broadcastPlaybackState]; + [self updateControls]; + [self updateNowPlayingInfo]; + result(@(YES)); + } else if ([@"setQueue" isEqualToString:call.method]) { + queue = call.arguments; + [channel invokeMethod:@"onQueueChanged" arguments:@[queue]]; + result(@YES); + } else if ([@"setMediaItem" isEqualToString:call.method]) { + mediaItem = call.arguments; + NSString* artUri = mediaItem[@"artUri"]; + artwork = nil; + if (![artUri isEqual: [NSNull null]]) { + NSString* artCacheFilePath = [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]; + [channel invokeMethod:@"onMediaChanged" arguments:@[call.arguments]]; + result(@(YES)); + } else if ([@"notifyChildrenChanged" isEqualToString:call.method]) { + result(@YES); + } else if ([@"androidForceEnableMediaButtons" isEqualToString:call.method]) { + result(@YES); + } else { + // TODO: Check if this implementation is correct. + // Can I just pass on the result as the last argument? + [backgroundChannel invokeMethod:call.method arguments:call.arguments result: result]; + } +} + +- (MPRemoteCommandHandlerStatus) play: (MPRemoteCommandEvent *) event { + NSLog(@"play"); + [backgroundChannel invokeMethod:@"onPlay" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) pause: (MPRemoteCommandEvent *) event { + NSLog(@"pause"); + [backgroundChannel invokeMethod:@"onPause" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (void) updateNowPlayingInfo { + NSMutableDictionary *nowPlayingInfo = [NSMutableDictionary new]; + if (mediaItem) { + nowPlayingInfo[MPMediaItemPropertyTitle] = mediaItem[@"title"]; + nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = mediaItem[@"album"]; + if (mediaItem[@"artist"] != [NSNull null]) { + nowPlayingInfo[MPMediaItemPropertyArtist] = mediaItem[@"artist"]; + } + if (mediaItem[@"duration"] != [NSNull null]) { + nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = [NSNumber numberWithLongLong: ([mediaItem[@"duration"] longLongValue] / 1000)]; + } + if (@available(iOS 3.0, macOS 10.13.2, *)) { + if (artwork) { + nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork; + } + } + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = [NSNumber numberWithInt:([position intValue] / 1000)]; + } + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = [NSNumber numberWithDouble: playing ? 1.0 : 0.0]; + [MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = nowPlayingInfo; +} + +- (void) updateControls { + for (enum MediaAction action = AStop; action <= ASeekForward; action++) { + [self updateControl:action]; + } + _controlsUpdated = YES; +} + +- (void) updateControl:(enum MediaAction)action { + MPRemoteCommand *command = commands[action]; + if (command == [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; + [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]; + } + } + 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; + } +} + +- (MPRemoteCommandHandlerStatus) togglePlayPause: (MPRemoteCommandEvent *) event { + NSLog(@"togglePlayPause"); + [backgroundChannel invokeMethod:@"onClick" arguments:@[@(0)]]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) stop: (MPRemoteCommandEvent *) event { + NSLog(@"stop"); + [backgroundChannel invokeMethod:@"onStop" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) nextTrack: (MPRemoteCommandEvent *) event { + NSLog(@"nextTrack"); + [backgroundChannel invokeMethod:@"onSkipToNext" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) previousTrack: (MPRemoteCommandEvent *) event { + NSLog(@"previousTrack"); + [backgroundChannel invokeMethod:@"onSkipToPrevious" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) changePlaybackPosition: (MPChangePlaybackPositionCommandEvent *) event { + NSLog(@"changePlaybackPosition"); + [backgroundChannel invokeMethod:@"onSeekTo" arguments: @[@((long long) (event.positionTime * 1000))]]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) skipForward: (MPRemoteCommandEvent *) event { + NSLog(@"skipForward"); + [backgroundChannel invokeMethod:@"onFastForward" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) skipBackward: (MPRemoteCommandEvent *) event { + NSLog(@"skipBackward"); + [backgroundChannel invokeMethod:@"onRewind" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) seekForward: (MPSeekCommandEvent *) event { + NSLog(@"seekForward"); + BOOL begin = event.type == MPSeekCommandEventTypeBeginSeeking; + [backgroundChannel invokeMethod:@"onSeekForward" arguments:@[@(begin)]]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) seekBackward: (MPSeekCommandEvent *) event { + NSLog(@"seekBackward"); + BOOL begin = event.type == MPSeekCommandEventTypeBeginSeeking; + [backgroundChannel invokeMethod:@"onSeekBackward" arguments:@[@(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; + } + [backgroundChannel invokeMethod:@"onSetRepeatMode" arguments:@[@(modeIndex)]]; + 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; + } + [backgroundChannel invokeMethod:@"onSetShuffleMode" arguments:@[@(modeIndex)]]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (void) dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +@end diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..47bf118 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,37 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/flutter_export_environment.sh diff --git a/ios/Assets/.gitkeep b/ios/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ios/Classes/AudioServicePlugin.h b/ios/Classes/AudioServicePlugin.h new file mode 100644 index 0000000..63e092c --- /dev/null +++ b/ios/Classes/AudioServicePlugin.h @@ -0,0 +1,54 @@ +#import + +@interface AudioServicePlugin : NSObject +@end + +enum AudioProcessingState { + none, + connecting, + ready, + buffering, + fastForwarding, + rewinding, + skippingToPrevious, + skippingToNext, + skippingToQueueItem, + completed, + stopped, + error +}; + +enum AudioInterruption { + AIPause, + AITemporaryPause, + AITemporaryDuck, + AIUnknownPause +}; + +enum MediaAction { + AStop, + APause, + APlay, + ARewind, + ASkipToPrevious, + ASkipToNext, + AFastForward, + ASetRating, + ASeekTo, + APlayPause, + APlayFromMediaId, + APlayFromSearch, + ASkipToQueueItem, + APlayFromUri, + APrepare, + APrepareFromMediaId, + APrepareFromSearch, + APrepareFromUri, + ASetRepeatMode, + AUnused_1, // deprecated (setShuffleModeEnabled) + AUnused_2, // setCaptioningEnabled + ASetShuffleMode, + // Non-standard + ASeekBackward, + ASeekForward, +}; diff --git a/ios/Classes/AudioServicePlugin.m b/ios/Classes/AudioServicePlugin.m new file mode 100644 index 0000000..48e8463 --- /dev/null +++ b/ios/Classes/AudioServicePlugin.m @@ -0,0 +1,617 @@ +#import "AudioServicePlugin.h" +#import +#import + +// 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! + +@implementation AudioServicePlugin + +static FlutterMethodChannel *channel = nil; +static FlutterMethodChannel *backgroundChannel = nil; +static BOOL _running = NO; +static FlutterResult startResult = nil; +static MPRemoteCommandCenter *commandCenter = nil; +static NSArray *queue = nil; +static NSMutableDictionary *mediaItem = nil; +static long actionBits; +static NSArray *commands; +static BOOL _controlsUpdated = NO; +static enum AudioProcessingState processingState = none; +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 NSMutableDictionary *params = nil; +static MPMediaItemArtwork* artwork = nil; + ++ (void)registerWithRegistrar:(NSObject*)registrar { + @synchronized(self) { + // TODO: Need a reliable way to detect whether this is the client + // or background. + // TODO: Handle multiple clients. + // As no separate isolate is used on macOS, add both handlers to the one registrar. +#if TARGET_OS_IPHONE + if (channel == nil) { +#endif + AudioServicePlugin *instance = [[AudioServicePlugin alloc] init:registrar]; + channel = [FlutterMethodChannel + methodChannelWithName:@"ryanheise.com/audioService" + binaryMessenger:[registrar messenger]]; + [registrar addMethodCallDelegate:instance channel:channel]; +#if TARGET_OS_IPHONE + } else { + AudioServicePlugin *instance = [[AudioServicePlugin alloc] init:registrar]; +#endif + backgroundChannel = [FlutterMethodChannel + methodChannelWithName:@"ryanheise.com/audioServiceBackground" + binaryMessenger:[registrar messenger]]; + [registrar addMethodCallDelegate:instance channel:backgroundChannel]; +#if TARGET_OS_IPHONE + } +#endif + } +} + +- (instancetype)init:(NSObject *)registrar { + self = [super init]; + NSAssert(self, @"super init cannot be nil"); + return self; +} + +- (void)broadcastPlaybackState { + [channel invokeMethod:@"onPlaybackStateChanged" arguments:@[ + // processingState + @(processingState), + // playing + @(playing), + // actions + @(actionBits), + // position + position, + // bufferedPosition + bufferedPosition, + // playback speed + speed, + // update time since epoch + updateTime, + // repeat mode + repeatMode, + // shuffle mode + shuffleMode, + ]]; +} + +- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + // TODO: + // - Restructure this so that we have a separate method call delegate + // for the client instance and the background instance so that methods + // can't be called on the wrong instance. + if ([@"connect" isEqualToString:call.method]) { + long long msSinceEpoch = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); + if (position == nil) { + position = @(0); + bufferedPosition = @(0); + updateTime = [NSNumber numberWithLongLong: msSinceEpoch]; + speed = [NSNumber numberWithDouble: 1.0]; + repeatMode = @(0); + shuffleMode = @(0); + } + // Notify client of state on subscribing. + [self broadcastPlaybackState]; + [channel invokeMethod:@"onMediaChanged" arguments:@[mediaItem ? mediaItem : [NSNull null]]]; + [channel invokeMethod:@"onQueueChanged" arguments:@[queue ? queue : [NSNull null]]]; + + result(nil); + } else if ([@"disconnect" isEqualToString:call.method]) { + result(nil); + } else if ([@"start" isEqualToString:call.method]) { + if (_running) { + result(@NO); + return; + } + _running = YES; + // The result will be sent after the background task actually starts. + // See the "ready" case below. + startResult = result; + +#if TARGET_OS_IPHONE + [AVAudioSession sharedInstance]; +#endif + + // Set callbacks on MPRemoteCommandCenter + fastForwardInterval = [call.arguments objectForKey:@"fastForwardInterval"]; + rewindInterval = [call.arguments objectForKey:@"rewindInterval"]; + commandCenter = [MPRemoteCommandCenter sharedCommandCenter]; + commands = @[ + commandCenter.stopCommand, + commandCenter.pauseCommand, + commandCenter.playCommand, + commandCenter.skipBackwardCommand, + commandCenter.previousTrackCommand, + commandCenter.nextTrackCommand, + commandCenter.skipForwardCommand, + [NSNull null], + commandCenter.changePlaybackPositionCommand, + 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 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]; + [self updateControls]; + + // Params + params = [call.arguments objectForKey:@"params"]; + +#if TARGET_OS_OSX + // No isolate can be used for macOS until https://github.com/flutter/flutter/issues/65222 is resolved. + // We send a result here, and then the Dart code continues in the main isolate. + result(@YES); +#endif + } else if ([@"ready" isEqualToString:call.method]) { + NSMutableDictionary *startParams = [NSMutableDictionary new]; + startParams[@"fastForwardInterval"] = fastForwardInterval; + startParams[@"rewindInterval"] = rewindInterval; + startParams[@"params"] = params; + result(startParams); + } else if ([@"started" isEqualToString:call.method]) { +#if TARGET_OS_IPHONE + if (startResult) { + startResult(@YES); + startResult = nil; + } +#endif + result(@YES); + } else if ([@"stopped" isEqualToString:call.method]) { + _running = NO; + [channel invokeMethod:@"onStopped" arguments:nil]; + [commandCenter.changePlaybackRateCommand setEnabled:NO]; + [commandCenter.togglePlayPauseCommand setEnabled:NO]; + [commandCenter.togglePlayPauseCommand removeTarget:nil]; + [MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = nil; + processingState = none; + playing = NO; + position = nil; + bufferedPosition = nil; + updateTime = nil; + speed = nil; + artwork = nil; + mediaItem = nil; + repeatMode = @(0); + shuffleMode = @(0); + actionBits = 0; + [self updateControls]; + _controlsUpdated = NO; + queue = nil; + startResult = nil; + fastForwardInterval = nil; + rewindInterval = nil; + params = nil; + commandCenter = nil; + result(@YES); + } else if ([@"isRunning" isEqualToString:call.method]) { + if (_running) { + result(@YES); + } else { + result(@NO); + } + } else if ([@"setBrowseMediaParent" isEqualToString:call.method]) { + result(@YES); + } else if ([@"addQueueItem" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onAddQueueItem" arguments:@[call.arguments] result: result]; + } else if ([@"addQueueItemAt" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onAddQueueItemAt" arguments:call.arguments result: result]; + } else if ([@"removeQueueItem" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onRemoveQueueItem" arguments:@[call.arguments] result: result]; + } else if ([@"updateQueue" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onUpdateQueue" arguments:@[call.arguments] result: result]; + } else if ([@"updateMediaItem" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onUpdateMediaItem" arguments:@[call.arguments] result: result]; + } else if ([@"click" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onClick" arguments:@[call.arguments] result: result]; + } else if ([@"prepare" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPrepare" arguments:nil result: result]; + } else if ([@"prepareFromMediaId" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPrepareFromMediaId" arguments:@[call.arguments] result: result]; + } else if ([@"play" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPlay" arguments:nil result: result]; + } else if ([@"playFromMediaId" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPlayFromMediaId" arguments:@[call.arguments] result: result]; + } else if ([@"playMediaItem" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPlayMediaItem" arguments:@[call.arguments] result: result]; + } else if ([@"skipToQueueItem" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSkipToQueueItem" arguments:@[call.arguments] result: result]; + } else if ([@"pause" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPause" arguments:nil result: result]; + } else if ([@"stop" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onStop" arguments:nil result: result]; + } else if ([@"seekTo" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSeekTo" arguments:@[call.arguments] result: result]; + } else if ([@"skipToNext" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSkipToNext" arguments:nil result: result]; + } else if ([@"skipToPrevious" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSkipToPrevious" arguments:nil result: result]; + } else if ([@"fastForward" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onFastForward" arguments:nil result: result]; + } else if ([@"rewind" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onRewind" arguments:nil result: result]; + } else if ([@"setRepeatMode" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSetRepeatMode" arguments:@[call.arguments] result: result]; + } else if ([@"setShuffleMode" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSetShuffleMode" arguments:@[call.arguments] result: result]; + } else if ([@"setRating" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSetRating" arguments:@[call.arguments[@"rating"], call.arguments[@"extras"]] result: result]; + } else if ([@"setSpeed" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSetSpeed" arguments:@[call.arguments] result: result]; + } else if ([@"seekForward" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSeekForward" arguments:@[call.arguments] result: result]; + } else if ([@"seekBackward" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSeekBackward" arguments:@[call.arguments] result: result]; + } else if ([@"setState" isEqualToString:call.method]) { + long long msSinceEpoch; + if (call.arguments[7] != [NSNull null]) { + msSinceEpoch = [call.arguments[7] longLongValue]; + } else { + msSinceEpoch = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); + } + actionBits = 0; + NSArray *controlsArray = call.arguments[0]; + 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 = call.arguments[1]; + for (int i = 0; i < systemActionsArray.count; i++) { + NSNumber *actionIndex = (NSNumber *)systemActionsArray[i]; + int actionCode = 1 << [actionIndex intValue]; + actionBits |= actionCode; + } + processingState = [call.arguments[2] intValue]; + playing = [call.arguments[3] boolValue]; + position = call.arguments[4]; + bufferedPosition = call.arguments[5]; + speed = call.arguments[6]; + repeatMode = call.arguments[9]; + shuffleMode = call.arguments[10]; + updateTime = [NSNumber numberWithLongLong: msSinceEpoch]; + [self broadcastPlaybackState]; + [self updateControls]; + [self updateNowPlayingInfo]; + result(@(YES)); + } else if ([@"setQueue" isEqualToString:call.method]) { + queue = call.arguments; + [channel invokeMethod:@"onQueueChanged" arguments:@[queue]]; + result(@YES); + } else if ([@"setMediaItem" isEqualToString:call.method]) { + mediaItem = call.arguments; + NSString* artUri = mediaItem[@"artUri"]; + artwork = nil; + if (![artUri isEqual: [NSNull null]]) { + NSString* artCacheFilePath = [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]; + [channel invokeMethod:@"onMediaChanged" arguments:@[call.arguments]]; + result(@(YES)); + } else if ([@"notifyChildrenChanged" isEqualToString:call.method]) { + result(@YES); + } else if ([@"androidForceEnableMediaButtons" isEqualToString:call.method]) { + result(@YES); + } else { + // TODO: Check if this implementation is correct. + // Can I just pass on the result as the last argument? + [backgroundChannel invokeMethod:call.method arguments:call.arguments result: result]; + } +} + +- (MPRemoteCommandHandlerStatus) play: (MPRemoteCommandEvent *) event { + NSLog(@"play"); + [backgroundChannel invokeMethod:@"onPlay" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) pause: (MPRemoteCommandEvent *) event { + NSLog(@"pause"); + [backgroundChannel invokeMethod:@"onPause" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (void) updateNowPlayingInfo { + NSMutableDictionary *nowPlayingInfo = [NSMutableDictionary new]; + if (mediaItem) { + nowPlayingInfo[MPMediaItemPropertyTitle] = mediaItem[@"title"]; + nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = mediaItem[@"album"]; + if (mediaItem[@"artist"] != [NSNull null]) { + nowPlayingInfo[MPMediaItemPropertyArtist] = mediaItem[@"artist"]; + } + if (mediaItem[@"duration"] != [NSNull null]) { + nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = [NSNumber numberWithLongLong: ([mediaItem[@"duration"] longLongValue] / 1000)]; + } + if (@available(iOS 3.0, macOS 10.13.2, *)) { + if (artwork) { + nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork; + } + } + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = [NSNumber numberWithInt:([position intValue] / 1000)]; + } + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = [NSNumber numberWithDouble: playing ? 1.0 : 0.0]; + [MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = nowPlayingInfo; +} + +- (void) updateControls { + for (enum MediaAction action = AStop; action <= ASeekForward; action++) { + [self updateControl:action]; + } + _controlsUpdated = YES; +} + +- (void) updateControl:(enum MediaAction)action { + MPRemoteCommand *command = commands[action]; + if (command == [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; + [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]; + } + } + 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; + } +} + +- (MPRemoteCommandHandlerStatus) togglePlayPause: (MPRemoteCommandEvent *) event { + NSLog(@"togglePlayPause"); + [backgroundChannel invokeMethod:@"onClick" arguments:@[@(0)]]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) stop: (MPRemoteCommandEvent *) event { + NSLog(@"stop"); + [backgroundChannel invokeMethod:@"onStop" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) nextTrack: (MPRemoteCommandEvent *) event { + NSLog(@"nextTrack"); + [backgroundChannel invokeMethod:@"onSkipToNext" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) previousTrack: (MPRemoteCommandEvent *) event { + NSLog(@"previousTrack"); + [backgroundChannel invokeMethod:@"onSkipToPrevious" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) changePlaybackPosition: (MPChangePlaybackPositionCommandEvent *) event { + NSLog(@"changePlaybackPosition"); + [backgroundChannel invokeMethod:@"onSeekTo" arguments: @[@((long long) (event.positionTime * 1000))]]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) skipForward: (MPRemoteCommandEvent *) event { + NSLog(@"skipForward"); + [backgroundChannel invokeMethod:@"onFastForward" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) skipBackward: (MPRemoteCommandEvent *) event { + NSLog(@"skipBackward"); + [backgroundChannel invokeMethod:@"onRewind" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) seekForward: (MPSeekCommandEvent *) event { + NSLog(@"seekForward"); + BOOL begin = event.type == MPSeekCommandEventTypeBeginSeeking; + [backgroundChannel invokeMethod:@"onSeekForward" arguments:@[@(begin)]]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) seekBackward: (MPSeekCommandEvent *) event { + NSLog(@"seekBackward"); + BOOL begin = event.type == MPSeekCommandEventTypeBeginSeeking; + [backgroundChannel invokeMethod:@"onSeekBackward" arguments:@[@(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; + } + [backgroundChannel invokeMethod:@"onSetRepeatMode" arguments:@[@(modeIndex)]]; + 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; + } + [backgroundChannel invokeMethod:@"onSetShuffleMode" arguments:@[@(modeIndex)]]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (void) dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +@end diff --git a/ios/audio_service.podspec b/ios/audio_service.podspec new file mode 100644 index 0000000..bdf3801 --- /dev/null +++ b/ios/audio_service.podspec @@ -0,0 +1,21 @@ +# +# 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 = 'A new flutter plugin project.' + s.description = <<-DESC +A new flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.public_header_files = 'Classes/**/*.h' + s.dependency 'Flutter' + + s.ios.deployment_target = '8.0' +end + diff --git a/lib/audio_service.dart b/lib/audio_service.dart new file mode 100644 index 0000000..ff59f58 --- /dev/null +++ b/lib/audio_service.dart @@ -0,0 +1,1765 @@ +import 'dart:async'; +import 'dart:io' show Platform; +import 'dart:isolate'; +import 'dart:ui' as ui; +import 'dart:ui'; + +import 'package:audio_session/audio_session.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:flutter_isolate/flutter_isolate.dart'; +import 'package:rxdart/rxdart.dart'; + +/// Name of port used to send custom events. +const _CUSTOM_EVENT_PORT_NAME = 'customEventPort'; + +/// The different buttons on a headset. +enum MediaButton { + media, + next, + previous, +} + +/// The actons associated with playing audio. +enum MediaAction { + stop, + pause, + play, + rewind, + skipToPrevious, + skipToNext, + fastForward, + setRating, + seekTo, + playPause, + playFromMediaId, + playFromSearch, + skipToQueueItem, + playFromUri, + prepare, + prepareFromMediaId, + prepareFromSearch, + prepareFromUri, + setRepeatMode, + unused_1, + unused_2, + setShuffleMode, + seekBackward, + seekForward, +} + +/// The different states during audio processing. +enum AudioProcessingState { + none, + connecting, + ready, + buffering, + fastForwarding, + rewinding, + skippingToPrevious, + skippingToNext, + skippingToQueueItem, + completed, + stopped, + error, +} + +/// The playback state for the audio service which includes a [playing] boolean +/// state, a processing state such as [AudioProcessingState.buffering], the +/// playback position and the currently enabled actions to be shown in the +/// Android notification or the iOS control center. +class PlaybackState { + /// The audio processing state e.g. [BasicPlaybackState.buffering]. + final AudioProcessingState processingState; + + /// Whether audio is either playing, or will play as soon as + /// [processingState] is [AudioProcessingState.ready]. A true value should + /// be broadcast whenever it would be appropriate for UIs to display a pause + /// or stop button. + /// + /// Since [playing] and [processingState] can vary independently, it is + /// possible distinguish a particular audio processing state while audio is + /// playing vs paused. For example, when buffering occurs during a seek, the + /// [processingState] can be [AudioProcessingState.buffering], but alongside + /// that [playing] can be true to indicate that the seek was performed while + /// playing, or false to indicate that the seek was performed while paused. + final bool playing; + + /// The set of actions currently supported by the audio service e.g. + /// [MediaAction.play]. + final Set actions; + + /// The playback position at the last update time. + final Duration position; + + /// The buffered position. + final Duration bufferedPosition; + + /// The current playback speed where 1.0 means normal speed. + final double speed; + + /// The time at which the playback position was last updated. + final Duration updateTime; + + /// The current repeat mode. + final AudioServiceRepeatMode repeatMode; + + /// The current shuffle mode. + final AudioServiceShuffleMode shuffleMode; + + const PlaybackState({ + @required this.processingState, + @required this.playing, + @required this.actions, + this.position, + this.bufferedPosition = Duration.zero, + this.speed, + this.updateTime, + this.repeatMode = AudioServiceRepeatMode.none, + this.shuffleMode = AudioServiceShuffleMode.none, + }); + + /// The current playback position. + Duration get currentPosition { + if (playing && processingState == AudioProcessingState.ready) { + return Duration( + milliseconds: (position.inMilliseconds + + ((DateTime.now().millisecondsSinceEpoch - + updateTime.inMilliseconds) * + (speed ?? 1.0))) + .toInt()); + } else { + return position; + } + } +} + +enum RatingStyle { + /// Indicates a rating style is not supported. + /// + /// A Rating will never have this type, but can be used by other classes + /// to indicate they do not support Rating. + none, + + /// A rating style with a single degree of rating, "heart" vs "no heart". + /// + /// Can be used to indicate the content referred to is a favorite (or not). + heart, + + /// A rating style for "thumb up" vs "thumb down". + thumbUpDown, + + /// A rating style with 0 to 3 stars. + range3stars, + + /// A rating style with 0 to 4 stars. + range4stars, + + /// A rating style with 0 to 5 stars. + range5stars, + + /// A rating style expressed as a percentage. + percentage, +} + +/// A rating to attach to a MediaItem. +class Rating { + final RatingStyle _type; + final dynamic _value; + + const Rating._internal(this._type, this._value); + + /// Create a new heart rating. + const Rating.newHeartRating(bool hasHeart) + : this._internal(RatingStyle.heart, hasHeart); + + /// Create a new percentage rating. + factory Rating.newPercentageRating(double percent) { + if (percent < 0 || percent > 100) throw ArgumentError(); + return Rating._internal(RatingStyle.percentage, percent); + } + + /// Create a new star rating. + factory Rating.newStartRating(RatingStyle starRatingStyle, int starRating) { + if (starRatingStyle != RatingStyle.range3stars && + starRatingStyle != RatingStyle.range4stars && + starRatingStyle != RatingStyle.range5stars) { + throw ArgumentError(); + } + if (starRating > starRatingStyle.index || starRating < 0) + throw ArgumentError(); + return Rating._internal(starRatingStyle, starRating); + } + + /// Create a new thumb rating. + const Rating.newThumbRating(bool isThumbsUp) + : this._internal(RatingStyle.thumbUpDown, isThumbsUp); + + /// Create a new unrated rating. + const Rating.newUnratedRating(RatingStyle ratingStyle) + : this._internal(ratingStyle, null); + + /// Return the rating style. + RatingStyle getRatingStyle() => _type; + + /// Returns a percentage rating value greater or equal to 0.0f, or a + /// negative value if the rating style is not percentage-based, or + /// if it is unrated. + double getPercentRating() { + if (_type != RatingStyle.percentage) return -1; + if (_value < 0 || _value > 100) return -1; + return _value ?? -1; + } + + /// Returns a rating value greater or equal to 0.0f, or a negative + /// value if the rating style is not star-based, or if it is + /// unrated. + int getStarRating() { + if (_type != RatingStyle.range3stars && + _type != RatingStyle.range4stars && + _type != RatingStyle.range5stars) return -1; + return _value ?? -1; + } + + /// Returns true if the rating is "heart selected" or false if the + /// rating is "heart unselected", if the rating style is not [heart] + /// or if it is unrated. + bool hasHeart() { + if (_type != RatingStyle.heart) return false; + return _value ?? false; + } + + /// Returns true if the rating is "thumb up" or false if the rating + /// is "thumb down", if the rating style is not [thumbUpDown] or if + /// it is unrated. + bool isThumbUp() { + if (_type != RatingStyle.thumbUpDown) return false; + return _value ?? false; + } + + /// Return whether there is a rating value available. + bool isRated() => _value != null; + + Map _toRaw() { + return { + 'type': _type.index, + 'value': _value, + }; + } + + // Even though this should take a Map, that makes an error. + Rating._fromRaw(Map raw) + : this._internal(RatingStyle.values[raw['type']], raw['value']); +} + +/// Metadata about an audio item that can be played, or a folder containing +/// audio items. +class MediaItem { + /// A unique id. + final String id; + + /// The album this media item belongs to. + final String album; + + /// The title of this media item. + final String title; + + /// The artist of this media item. + final String artist; + + /// The genre of this media item. + final String genre; + + /// The duration of this media item. + final Duration duration; + + /// The artwork for this media item as a uri. + final String artUri; + + /// Whether this is playable (i.e. not a folder). + final bool playable; + + /// Override the default title for display purposes. + final String displayTitle; + + /// Override the default subtitle for display purposes. + final String displaySubtitle; + + /// Override the default description for display purposes. + final String displayDescription; + + /// The rating of the MediaItem. + final Rating rating; + + /// A map of additional metadata for the media item. + /// + /// The values must be integers or strings. + final Map extras; + + /// Creates a [MediaItem]. + /// + /// [id], [album] and [title] must not be null, and [id] must be unique for + /// each instance. + const MediaItem({ + @required this.id, + @required this.album, + @required this.title, + this.artist, + this.genre, + this.duration, + this.artUri, + this.playable = true, + this.displayTitle, + this.displaySubtitle, + this.displayDescription, + this.rating, + this.extras, + }); + + /// Creates a [MediaItem] from a map of key/value pairs corresponding to + /// fields of this class. + factory MediaItem.fromJson(Map raw) => MediaItem( + id: raw['id'], + album: raw['album'], + title: raw['title'], + artist: raw['artist'], + genre: raw['genre'], + duration: raw['duration'] != null + ? Duration(milliseconds: raw['duration']) + : null, + playable: raw['playable']??true, + artUri: raw['artUri'], + displayTitle: raw['displayTitle'], + displaySubtitle: raw['displaySubtitle'], + displayDescription: raw['displayDescription'], + rating: raw['rating'] != null ? Rating._fromRaw(raw['rating']) : null, + extras: _raw2extras(raw['extras']), + ); + + /// Creates a copy of this [MediaItem] but with with the given fields + /// replaced by new values. + MediaItem copyWith({ + String id, + String album, + String title, + String artist, + String genre, + Duration duration, + String artUri, + bool playable, + String displayTitle, + String displaySubtitle, + String displayDescription, + Rating rating, + Map extras, + }) => + MediaItem( + id: id ?? this.id, + album: album ?? this.album, + title: title ?? this.title, + artist: artist ?? this.artist, + genre: genre ?? this.genre, + duration: duration ?? this.duration, + artUri: artUri ?? this.artUri, + playable: playable ?? this.playable, + displayTitle: displayTitle ?? this.displayTitle, + displaySubtitle: displaySubtitle ?? this.displaySubtitle, + displayDescription: displayDescription ?? this.displayDescription, + rating: rating ?? this.rating, + extras: extras ?? this.extras, + ); + + @override + int get hashCode => id.hashCode; + + @override + bool operator ==(dynamic other) => other is MediaItem && other.id == id; + + @override + String toString() => '${toJson()}'; + + /// Converts this [MediaItem] to a map of key/value pairs corresponding to + /// the fields of this class. + Map toJson() => { + 'id': id, + 'album': album, + 'title': title, + 'artist': artist, + 'genre': genre, + 'duration': duration?.inMilliseconds, + 'artUri': artUri, + 'playable': playable, + 'displayTitle': displayTitle, + 'displaySubtitle': displaySubtitle, + 'displayDescription': displayDescription, + 'rating': rating?._toRaw(), + 'extras': extras, + }; + + static Map _raw2extras(Map raw) { + if (raw == null) return null; + final extras = {}; + for (var key in raw.keys) { + extras[key as String] = raw[key]; + } + return extras; + } +} + +/// A button to appear in the Android notification, lock screen, Android smart +/// watch, or Android Auto device. The set of buttons you would like to display +/// at any given moment should be set via [AudioServiceBackground.setState]. +/// +/// Each [MediaControl] button controls a specified [MediaAction]. Only the +/// following actions can be represented as buttons: +/// +/// * [MediaAction.stop] +/// * [MediaAction.pause] +/// * [MediaAction.play] +/// * [MediaAction.rewind] +/// * [MediaAction.skipToPrevious] +/// * [MediaAction.skipToNext] +/// * [MediaAction.fastForward] +/// * [MediaAction.playPause] +/// +/// Predefined controls with default Android icons and labels are defined as +/// static fields of this class. If you wish to define your own custom Android +/// controls with your own icon resources, you will need to place the Android +/// resources in `android/app/src/main/res`. Here, you will find a subdirectory +/// for each different resolution: +/// +/// ``` +/// drawable-hdpi +/// drawable-mdpi +/// drawable-xhdpi +/// drawable-xxhdpi +/// drawable-xxxhdpi +/// ``` +/// +/// You can use [Android Asset +/// Studio](https://romannurik.github.io/AndroidAssetStudio/) to generate these +/// different subdirectories for any standard material design icon. +class MediaControl { + /// A default control for [MediaAction.stop]. + static final stop = MediaControl( + androidIcon: 'drawable/audio_service_stop', + label: 'Stop', + action: MediaAction.stop, + ); + + /// A default control for [MediaAction.pause]. + static final pause = MediaControl( + androidIcon: 'drawable/audio_service_pause', + label: 'Pause', + action: MediaAction.pause, + ); + + /// A default control for [MediaAction.play]. + static final play = MediaControl( + androidIcon: 'drawable/audio_service_play_arrow', + label: 'Play', + action: MediaAction.play, + ); + + /// A default control for [MediaAction.rewind]. + static final rewind = MediaControl( + androidIcon: 'drawable/audio_service_fast_rewind', + label: 'Rewind', + action: MediaAction.rewind, + ); + + /// A default control for [MediaAction.skipToNext]. + static final skipToNext = MediaControl( + androidIcon: 'drawable/audio_service_skip_next', + label: 'Next', + action: MediaAction.skipToNext, + ); + + /// A default control for [MediaAction.skipToPrevious]. + static final skipToPrevious = MediaControl( + androidIcon: 'drawable/audio_service_skip_previous', + label: 'Previous', + action: MediaAction.skipToPrevious, + ); + + /// A default control for [MediaAction.fastForward]. + static final fastForward = MediaControl( + androidIcon: 'drawable/audio_service_fast_forward', + label: 'Fast Forward', + action: MediaAction.fastForward, + ); + + /// A reference to an Android icon resource for the control (e.g. + /// `"drawable/ic_action_pause"`) + final String androidIcon; + + /// A label for the control + final String label; + + /// The action to be executed by this control + final MediaAction action; + + const MediaControl({ + @required this.androidIcon, + @required this.label, + @required this.action, + }); +} + +const MethodChannel _channel = + const MethodChannel('ryanheise.com/audioService'); + +const String _CUSTOM_PREFIX = 'custom_'; + +/// Client API to start and interact with the audio service. +/// +/// This class is used from your UI code to establish a connection with the +/// audio service. While connected to the service, your UI may invoke methods +/// of this class to start/pause/stop/etc. playback and listen to changes in +/// playback state and playing media. +/// +/// Your UI must disconnect from the audio service when it is no longer visible +/// although the audio service will continue to run in the background. If your +/// UI once again becomes visible, you should reconnect to the audio service. +/// +/// It is recommended to use [AudioServiceWidget] to manage this connection +/// automatically. +class AudioService { + /// True if the background task runs in its own isolate, false if it doesn't. + static bool get usesIsolate => !(kIsWeb || Platform.isMacOS); + + /// The root media ID for browsing media provided by the background + /// task. + static const String MEDIA_ROOT_ID = "root"; + + static final _browseMediaChildrenSubject = BehaviorSubject>(); + + /// A stream that broadcasts the children of the current browse + /// media parent. + static Stream> get browseMediaChildrenStream => + _browseMediaChildrenSubject.stream; + + static final _playbackStateSubject = BehaviorSubject(); + + /// A stream that broadcasts the playback state. + static Stream get playbackStateStream => + _playbackStateSubject.stream; + + static final _currentMediaItemSubject = BehaviorSubject(); + + /// A stream that broadcasts the current [MediaItem]. + static Stream get currentMediaItemStream => + _currentMediaItemSubject.stream; + + static final _queueSubject = BehaviorSubject>(); + + /// A stream that broadcasts the queue. + static Stream> get queueStream => _queueSubject.stream; + + static final _notificationSubject = BehaviorSubject.seeded(false); + + /// A stream that broadcasts the status of notificationClick event. + static Stream get notificationClickEventStream => + _notificationSubject.stream; + + static final _customEventSubject = PublishSubject(); + + /// A stream that broadcasts custom events sent from the background. + static Stream get customEventStream => _customEventSubject.stream; + + /// The children of the current browse media parent. + static List get browseMediaChildren => _browseMediaChildren; + static List _browseMediaChildren; + + /// The current playback state. + static PlaybackState get playbackState => _playbackState; + static PlaybackState _playbackState; + + /// The current media item. + static MediaItem get currentMediaItem => _currentMediaItem; + static MediaItem _currentMediaItem; + + /// The current queue. + static List get queue => _queue; + static List _queue; + + /// True after service stopped and !running. + static bool _afterStop = false; + + /// Receives custom events from the background audio task. + static ReceivePort _customEventReceivePort; + static StreamSubscription _customEventSubscription; + + /// Connects to the service from your UI so that audio playback can be + /// controlled. + /// + /// This method should be called when your UI becomes visible, and + /// [disconnect] should be called when your UI is no longer visible. All + /// other methods in this class will work only while connected. + /// + /// Use [AudioServiceWidget] to handle this automatically. + static Future connect() async { + _channel.setMethodCallHandler((MethodCall call) async { + switch (call.method) { + case 'onChildrenLoaded': + final List args = List.from(call.arguments[0]); + _browseMediaChildren = + args.map((raw) => MediaItem.fromJson(raw)).toList(); + _browseMediaChildrenSubject.add(_browseMediaChildren); + break; + case 'onPlaybackStateChanged': + // If this event arrives too late, ignore it. + if (_afterStop) return; + final List args = call.arguments; + int actionBits = args[2]; + _playbackState = PlaybackState( + processingState: AudioProcessingState.values[args[0]], + playing: args[1], + actions: MediaAction.values + .where((action) => (actionBits & (1 << action.index)) != 0) + .toSet(), + position: Duration(milliseconds: args[3]), + bufferedPosition: Duration(milliseconds: args[4]), + speed: args[5], + updateTime: Duration(milliseconds: args[6]), + repeatMode: AudioServiceRepeatMode.values[args[7]], + shuffleMode: AudioServiceShuffleMode.values[args[8]], + ); + _playbackStateSubject.add(_playbackState); + break; + case 'onMediaChanged': + _currentMediaItem = call.arguments[0] != null + ? MediaItem.fromJson(call.arguments[0]) + : null; + _currentMediaItemSubject.add(_currentMediaItem); + break; + case 'onQueueChanged': + final List args = call.arguments[0] != null + ? List.from(call.arguments[0]) + : null; + _queue = args?.map((raw) => MediaItem.fromJson(raw))?.toList(); + _queueSubject.add(_queue); + break; + case 'onStopped': + _browseMediaChildren = null; + _browseMediaChildrenSubject.add(null); + _playbackState = null; + _playbackStateSubject.add(null); + _currentMediaItem = null; + _currentMediaItemSubject.add(null); + _queue = null; + _queueSubject.add(null); + _notificationSubject.add(false); + _running = false; + _afterStop = true; + break; + case 'notificationClicked': + _notificationSubject.add(call.arguments[0]); + break; + } + }); + if (AudioService.usesIsolate) { + _customEventReceivePort = ReceivePort(); + _customEventSubscription = _customEventReceivePort.listen((event) { + _customEventSubject.add(event); + }); + IsolateNameServer.removePortNameMapping(_CUSTOM_EVENT_PORT_NAME); + IsolateNameServer.registerPortWithName( + _customEventReceivePort.sendPort, _CUSTOM_EVENT_PORT_NAME); + } + await _channel.invokeMethod("connect"); + _running = await _channel.invokeMethod("isRunning"); + _connected = true; + } + + /// Disconnects your UI from the service. + /// + /// This method should be called when the UI is no longer visible. + /// + /// Use [AudioServiceWidget] to handle this automatically. + static Future disconnect() async { + _channel.setMethodCallHandler(null); + _customEventSubscription?.cancel(); + _customEventSubscription = null; + _customEventReceivePort = null; + await _channel.invokeMethod("disconnect"); + _connected = false; + } + + /// True if the UI is connected. + static bool get connected => _connected; + static bool _connected = false; + + /// True if the background audio task is running. + static bool get running => _running; + static bool _running = false; + + /// Starts a background audio task which will continue running even when the + /// UI is not visible or the screen is turned off. Only one background audio task + /// may be running at a time. + /// + /// While the background task is running, it will display a system + /// notification showing information about the current media item being + /// played (see [AudioServiceBackground.setMediaItem]) along with any media + /// controls to perform any media actions that you want to support (see + /// [AudioServiceBackground.setState]). + /// + /// The background task is specified by [backgroundTaskEntrypoint] which will + /// be run within a background isolate. This function must be a top-level + /// function, and it must initiate execution by calling + /// [AudioServiceBackground.run]. Because the background task runs in an + /// isolate, no memory is shared between the background isolate and your main + /// UI isolate and so all communication between the background task and your + /// UI is achieved through message passing. + /// + /// The [androidNotificationIcon] is specified like an XML resource reference + /// and defaults to `"mipmap/ic_launcher"`. + /// + /// If specified, [androidArtDownscaleSize] causes artwork to be downscaled + /// to the given resolution in pixels before being displayed in the + /// notification and lock screen. If not specified, no downscaling will be + /// performed. If the resolution of your artwork is particularly high, + /// downscaling can help to conserve memory. + /// + /// [params] provides a way to pass custom parameters through to the + /// `onStart` method of your background audio task. If specified, this must + /// be a map consisting of keys/values that can be encoded via Flutter's + /// `StandardMessageCodec`. + /// + /// [fastForwardInterval] and [rewindInterval] are passed through to your + /// background audio task as properties, and they represent the duration + /// of audio that should be skipped in fast forward / rewind operations. On + /// iOS, these values also configure the intervals for the skip forward and + /// skip backward buttons. + /// + /// [androidEnableQueue] enables queue support on the media session on + /// Android. If your app will run on Android and has a queue, you should set + /// this to true. + /// + /// This method waits for [BackgroundAudioTask.onStart] to complete, and + /// completes with true if the task was successfully started, or false + /// otherwise. + static Future start({ + @required Function backgroundTaskEntrypoint, + Map params, + String androidNotificationChannelName = "Notifications", + String androidNotificationChannelDescription, + int androidNotificationColor, + String androidNotificationIcon = 'mipmap/ic_launcher', + bool androidNotificationClickStartsActivity = true, + bool androidNotificationOngoing = false, + bool androidResumeOnClick = true, + bool androidStopForegroundOnPause = false, + bool androidEnableQueue = false, + Size androidArtDownscaleSize, + Duration fastForwardInterval = const Duration(seconds: 10), + Duration rewindInterval = const Duration(seconds: 10), + }) async { + if (_running) return false; + _running = true; + _afterStop = false; + ui.CallbackHandle handle; + if (AudioService.usesIsolate) { + handle = ui.PluginUtilities.getCallbackHandle(backgroundTaskEntrypoint); + if (handle == null) { + return false; + } + } + + var callbackHandle = handle?.toRawHandle(); + if (kIsWeb) { + // Platform throws runtime exceptions on web + } else if (Platform.isIOS) { + // NOTE: to maintain compatibility between the Android and iOS + // implementations, we ensure that the iOS background task also runs in + // an isolate. Currently, the standard Isolate API does not allow + // isolates to invoke methods on method channels. That may be fixed in + // the future, but until then, we use the flutter_isolate plugin which + // creates a FlutterNativeView for us, similar to what the Android + // implementation does. + // TODO: remove dependency on flutter_isolate by either using the + // FlutterNativeView API directly or by waiting until Flutter allows + // regular isolates to use method channels. + await FlutterIsolate.spawn(_iosIsolateEntrypoint, callbackHandle); + } + final success = await _channel.invokeMethod('start', { + 'callbackHandle': callbackHandle, + 'params': params, + 'androidNotificationChannelName': androidNotificationChannelName, + 'androidNotificationChannelDescription': + androidNotificationChannelDescription, + 'androidNotificationColor': androidNotificationColor, + 'androidNotificationIcon': androidNotificationIcon, + 'androidNotificationClickStartsActivity': + androidNotificationClickStartsActivity, + 'androidNotificationOngoing': androidNotificationOngoing, + 'androidResumeOnClick': androidResumeOnClick, + 'androidStopForegroundOnPause': androidStopForegroundOnPause, + 'androidEnableQueue': androidEnableQueue, + 'androidArtDownscaleSize': androidArtDownscaleSize != null + ? { + 'width': androidArtDownscaleSize.width, + 'height': androidArtDownscaleSize.height + } + : null, + 'fastForwardInterval': fastForwardInterval.inMilliseconds, + 'rewindInterval': rewindInterval.inMilliseconds, + }); + _running = await _channel.invokeMethod("isRunning"); + if (!AudioService.usesIsolate) backgroundTaskEntrypoint(); + return success; + } + + /// Sets the parent of the children that [browseMediaChildrenStream] broadcasts. + /// If unspecified, the root parent will be used. + static Future setBrowseMediaParent( + [String parentMediaId = MEDIA_ROOT_ID]) async { + await _channel.invokeMethod('setBrowseMediaParent', parentMediaId); + } + + /// Sends a request to your background audio task to add an item to the + /// queue. This passes through to the `onAddQueueItem` method in your + /// background audio task. + static Future addQueueItem(MediaItem mediaItem) async { + await _channel.invokeMethod('addQueueItem', mediaItem.toJson()); + } + + /// Sends a request to your background audio task to add a item to the queue + /// at a particular position. This passes through to the `onAddQueueItemAt` + /// method in your background audio task. + static Future addQueueItemAt(MediaItem mediaItem, int index) async { + await _channel.invokeMethod('addQueueItemAt', [mediaItem.toJson(), index]); + } + + /// Sends a request to your background audio task to remove an item from the + /// queue. This passes through to the `onRemoveQueueItem` method in your + /// background audio task. + static Future removeQueueItem(MediaItem mediaItem) async { + await _channel.invokeMethod('removeQueueItem', mediaItem.toJson()); + } + + /// A convenience method calls [addQueueItem] for each media item in the + /// given list. Note that this will be inefficient if you are adding a lot + /// of media items at once. If possible, you should use [updateQueue] + /// instead. + static Future addQueueItems(List mediaItems) async { + for (var mediaItem in mediaItems) { + await addQueueItem(mediaItem); + } + } + + /// Sends a request to your background audio task to replace the queue with a + /// new list of media items. This passes through to the `onUpdateQueue` + /// method in your background audio task. + static Future updateQueue(List queue) async { + await _channel.invokeMethod( + 'updateQueue', queue.map((item) => item.toJson()).toList()); + } + + /// Sends a request to your background audio task to update the details of a + /// media item. This passes through to the 'onUpdateMediaItem' method in your + /// background audio task. + static Future updateMediaItem(MediaItem mediaItem) async { + await _channel.invokeMethod('updateMediaItem', mediaItem.toJson()); + } + + /// Programmatically simulates a click of a media button on the headset. + /// + /// This passes through to `onClick` in the background audio task. + static Future click([MediaButton button = MediaButton.media]) async { + await _channel.invokeMethod('click', button.index); + } + + /// Sends a request to your background audio task to prepare for audio + /// playback. This passes through to the `onPrepare` method in your + /// background audio task. + static Future prepare() async { + await _channel.invokeMethod('prepare'); + } + + /// Sends a request to your background audio task to prepare for playing a + /// particular media item. This passes through to the `onPrepareFromMediaId` + /// method in your background audio task. + static Future prepareFromMediaId(String mediaId) async { + await _channel.invokeMethod('prepareFromMediaId', mediaId); + } + + //static Future prepareFromSearch(String query, Bundle extras) async {} + //static Future prepareFromUri(Uri uri, Bundle extras) async {} + + /// Sends a request to your background audio task to play the current media + /// item. This passes through to 'onPlay' in your background audio task. + static Future play() async { + await _channel.invokeMethod('play'); + } + + /// Sends a request to your background audio task to play a particular media + /// item referenced by its media id. This passes through to the + /// 'onPlayFromMediaId' method in your background audio task. + static Future playFromMediaId(String mediaId) async { + await _channel.invokeMethod('playFromMediaId', mediaId); + } + + /// Sends a request to your background audio task to play a particular media + /// item. This passes through to the 'onPlayMediaItem' method in your + /// background audio task. + static Future playMediaItem(MediaItem mediaItem) async { + await _channel.invokeMethod('playMediaItem', mediaItem.toJson()); + } + + //static Future playFromSearch(String query, Bundle extras) async {} + //static Future playFromUri(Uri uri, Bundle extras) async {} + + /// Sends a request to your background audio task to skip to a particular + /// item in the queue. This passes through to the `onSkipToQueueItem` method + /// in your background audio task. + static Future skipToQueueItem(String mediaId) async { + await _channel.invokeMethod('skipToQueueItem', mediaId); + } + + /// Sends a request to your background audio task to pause playback. This + /// passes through to the `onPause` method in your background audio task. + static Future pause() async { + await _channel.invokeMethod('pause'); + } + + /// Sends a request to your background audio task to stop playback and shut + /// down the task. This passes through to the `onStop` method in your + /// background audio task. + static Future stop() async { + await _channel.invokeMethod('stop'); + } + + /// Sends a request to your background audio task to seek to a particular + /// position in the current media item. This passes through to the `onSeekTo` + /// method in your background audio task. + static Future seekTo(Duration position) async { + await _channel.invokeMethod('seekTo', position.inMilliseconds); + } + + /// Sends a request to your background audio task to skip to the next item in + /// the queue. This passes through to the `onSkipToNext` method in your + /// background audio task. + static Future skipToNext() async { + await _channel.invokeMethod('skipToNext'); + } + + /// Sends a request to your background audio task to skip to the previous + /// item in the queue. This passes through to the `onSkipToPrevious` method + /// in your background audio task. + static Future skipToPrevious() async { + await _channel.invokeMethod('skipToPrevious'); + } + + /// Sends a request to your background audio task to fast forward by the + /// interval passed into the [start] method. This passes through to the + /// `onFastForward` method in your background audio task. + static Future fastForward() async { + await _channel.invokeMethod('fastForward'); + } + + /// Sends a request to your background audio task to rewind by the interval + /// passed into the [start] method. This passes through to the `onRewind` + /// method in the background audio task. + static Future rewind() async { + await _channel.invokeMethod('rewind'); + } + + //static Future setCaptioningEnabled(boolean enabled) async {} + + /// Sends a request to your background audio task to set the repeat mode. + /// This passes through to the `onSetRepeatMode` method in your background + /// audio task. + static Future setRepeatMode(AudioServiceRepeatMode repeatMode) async { + await _channel.invokeMethod('setRepeatMode', repeatMode.index); + } + + /// Sends a request to your background audio task to set the shuffle mode. + /// This passes through to the `onSetShuffleMode` method in your background + /// audio task. + static Future setShuffleMode( + AudioServiceShuffleMode shuffleMode) async { + await _channel.invokeMethod('setShuffleMode', shuffleMode.index); + } + + /// Sends a request to your background audio task to set a rating on the + /// current media item. This passes through to the `onSetRating` method in + /// your background audio task. The extras map must *only* contain primitive + /// types! + static Future setRating(Rating rating, + [Map extras]) async { + await _channel.invokeMethod('setRating', { + "rating": rating._toRaw(), + "extras": extras, + }); + } + + /// Sends a request to your background audio task to set the audio playback + /// speed. This passes through to the `onSetSpeed` method in your background + /// audio task. + static Future setSpeed(double speed) async { + await _channel.invokeMethod('setSpeed', speed); + } + + /// Sends a request to your background audio task to begin or end seeking + /// backward. This method passes through to the `onSeekBackward` method in + /// your background audio task. + static Future seekBackward(bool begin) async { + await _channel.invokeMethod('seekBackward', begin); + } + + /// Sends a request to your background audio task to begin or end seek + /// forward. This method passes through to the `onSeekForward` method in your + /// background audio task. + static Future seekForward(bool begin) async { + await _channel.invokeMethod('seekForward', begin); + } + + //static Future sendCustomAction(PlaybackStateCompat.CustomAction customAction, + //static Future sendCustomAction(String action, Bundle args) async {} + + /// Sends a custom request to your background audio task. This passes through + /// to the `onCustomAction` in your background audio task. + /// + /// This may be used for your own purposes. [arguments] can be any data that + /// is encodable by `StandardMessageCodec`. + static Future customAction(String name, [dynamic arguments]) async { + return await _channel.invokeMethod('$_CUSTOM_PREFIX$name', arguments); + } +} + +/// Background API to be used by your background audio task. +/// +/// The entry point of your background task that you passed to +/// [AudioService.start] is executed in an isolate that will run independently +/// of the view. Aside from its primary job of playing audio, your background +/// task should also use methods of this class to initialise the isolate, +/// broadcast state changes to any UI that may be connected, and to also handle +/// playback actions initiated by the UI. +class AudioServiceBackground { + static final PlaybackState _noneState = PlaybackState( + processingState: AudioProcessingState.none, + playing: false, + actions: Set(), + ); + static MethodChannel _backgroundChannel; + static PlaybackState _state = _noneState; + static MediaItem _mediaItem; + static List _queue; + static BaseCacheManager _cacheManager; + static BackgroundAudioTask _task; + + /// The current media playback state. + /// + /// This is the value most recently set via [setState]. + static PlaybackState get state => _state; + + /// The current media item. + /// + /// This is the value most recently set via [setMediaItem]. + static MediaItem get mediaItem => _mediaItem; + + /// The current queue. + /// + /// This is the value most recently set via [setQueue]. + static List get queue => _queue; + + /// Runs the background audio task within the background isolate. + /// + /// This must be the first method called by the entrypoint of your background + /// task that you passed into [AudioService.start]. The [BackgroundAudioTask] + /// returned by the [taskBuilder] parameter defines callbacks to handle the + /// initialization and distruction of the background audio task, as well as + /// any requests by the client to play, pause and otherwise control audio + /// playback. + static Future run(BackgroundAudioTask taskBuilder()) async { + _backgroundChannel = + const MethodChannel('ryanheise.com/audioServiceBackground'); + WidgetsFlutterBinding.ensureInitialized(); + _task = taskBuilder(); + _cacheManager = _task.cacheManager; + _backgroundChannel.setMethodCallHandler((MethodCall call) async { + try { + switch (call.method) { + case 'onLoadChildren': + final List args = call.arguments; + String parentMediaId = args[0]; + List mediaItems = + await _task.onLoadChildren(parentMediaId); + List rawMediaItems = + mediaItems.map((item) => item.toJson()).toList(); + return rawMediaItems as dynamic; + case 'onClick': + final List args = call.arguments; + MediaButton button = MediaButton.values[args[0]]; + await _task.onClick(button); + break; + case 'onStop': + await _task.onStop(); + break; + case 'onPause': + await _task.onPause(); + break; + case 'onPrepare': + await _task.onPrepare(); + break; + case 'onPrepareFromMediaId': + final List args = call.arguments; + String mediaId = args[0]; + await _task.onPrepareFromMediaId(mediaId); + break; + case 'onPlay': + await _task.onPlay(); + break; + case 'onPlayFromMediaId': + final List args = call.arguments; + String mediaId = args[0]; + await _task.onPlayFromMediaId(mediaId); + break; + case 'onPlayMediaItem': + await _task.onPlayMediaItem(MediaItem.fromJson(call.arguments[0])); + break; + case 'onAddQueueItem': + await _task.onAddQueueItem(MediaItem.fromJson(call.arguments[0])); + break; + case 'onAddQueueItemAt': + final List args = call.arguments; + MediaItem mediaItem = MediaItem.fromJson(args[0]); + int index = args[1]; + await _task.onAddQueueItemAt(mediaItem, index); + break; + case 'onUpdateQueue': + final List args = call.arguments; + final List queue = args[0]; + await _task.onUpdateQueue( + queue?.map((raw) => MediaItem.fromJson(raw))?.toList()); + break; + case 'onUpdateMediaItem': + await _task + .onUpdateMediaItem(MediaItem.fromJson(call.arguments[0])); + break; + case 'onRemoveQueueItem': + await _task + .onRemoveQueueItem(MediaItem.fromJson(call.arguments[0])); + break; + case 'onSkipToNext': + await _task.onSkipToNext(); + break; + case 'onSkipToPrevious': + await _task.onSkipToPrevious(); + break; + case 'onFastForward': + await _task.onFastForward(); + break; + case 'onRewind': + await _task.onRewind(); + break; + case 'onSkipToQueueItem': + final List args = call.arguments; + String mediaId = args[0]; + await _task.onSkipToQueueItem(mediaId); + break; + case 'onSeekTo': + final List args = call.arguments; + int positionMs = args[0]; + Duration position = Duration(milliseconds: positionMs); + await _task.onSeekTo(position); + break; + case 'onSetRepeatMode': + final List args = call.arguments; + await _task.onSetRepeatMode(AudioServiceRepeatMode.values[args[0]]); + break; + case 'onSetShuffleMode': + final List args = call.arguments; + await _task + .onSetShuffleMode(AudioServiceShuffleMode.values[args[0]]); + break; + case 'onSetRating': + await _task.onSetRating( + Rating._fromRaw(call.arguments[0]), call.arguments[1]); + break; + case 'onSeekBackward': + final List args = call.arguments; + await _task.onSeekBackward(args[0]); + break; + case 'onSeekForward': + final List args = call.arguments; + await _task.onSeekForward(args[0]); + break; + case 'onSetSpeed': + final List args = call.arguments; + double speed = args[0]; + await _task.onSetSpeed(speed); + break; + case 'onTaskRemoved': + await _task.onTaskRemoved(); + break; + case 'onClose': + await _task.onClose(); + break; + default: + if (call.method.startsWith(_CUSTOM_PREFIX)) { + final result = await _task.onCustomAction( + call.method.substring(_CUSTOM_PREFIX.length), call.arguments); + return result; + } + break; + } + } catch (e, stacktrace) { + print('$stacktrace'); + throw PlatformException(code: '$e'); + } + }); + Map startParams = await _backgroundChannel.invokeMethod('ready'); + Duration fastForwardInterval = + Duration(milliseconds: startParams['fastForwardInterval']); + Duration rewindInterval = + Duration(milliseconds: startParams['rewindInterval']); + Map params = + startParams['params']?.cast(); + _task._setParams( + fastForwardInterval: fastForwardInterval, + rewindInterval: rewindInterval, + ); + try { + await _task.onStart(params); + } catch (e) {} finally { + // For now, we return successfully from AudioService.start regardless of + // whether an exception occurred in onStart. + await _backgroundChannel.invokeMethod('started'); + } + } + + /// Shuts down the background audio task within the background isolate. + static Future _shutdown() async { + final audioSession = await AudioSession.instance; + try { + await audioSession.setActive(false); + } catch (e) { + print("While deactivating audio session: $e"); + } + await _backgroundChannel.invokeMethod('stopped'); + if (kIsWeb) { + } else if (Platform.isIOS) { + FlutterIsolate.current?.kill(); + } + _backgroundChannel.setMethodCallHandler(null); + _state = _noneState; + } + + /// Broadcasts to all clients the current state, including: + /// + /// * Whether media is playing or paused + /// * Whether media is buffering or skipping + /// * The current position, buffered position and speed + /// * The current set of media actions that should be enabled + /// + /// Connected clients will use this information to update their UI. + /// + /// You should use [controls] to specify the set of clickable buttons that + /// should currently be visible in the notification in the current state, + /// where each button is a [MediaControl] that triggers a different + /// [MediaAction]. Only the following actions can be enabled as + /// [MediaControl]s: + /// + /// * [MediaAction.stop] + /// * [MediaAction.pause] + /// * [MediaAction.play] + /// * [MediaAction.rewind] + /// * [MediaAction.skipToPrevious] + /// * [MediaAction.skipToNext] + /// * [MediaAction.fastForward] + /// * [MediaAction.playPause] + /// + /// Any other action you would like to enable for clients that is not a clickable + /// notification button should be specified in the [systemActions] parameter. For + /// example: + /// + /// * [MediaAction.seekTo] (enable a seek bar) + /// * [MediaAction.seekForward] (enable press-and-hold fast-forward control) + /// * [MediaAction.seekBackward] (enable press-and-hold rewind control) + /// + /// In practice, iOS will treat all entries in [controls] and [systemActions] + /// in the same way since you cannot customise the icons of controls in the + /// Control Center. However, on Android, the distinction is important as clickable + /// buttons in the notification require you to specify your own icon. + /// + /// Note that specifying [MediaAction.seekTo] in [systemActions] will enable + /// a seek bar in both the Android notification and the iOS control center. + /// [MediaAction.seekForward] and [MediaAction.seekBackward] have a special + /// behaviour on iOS in which if you have already enabled the + /// [MediaAction.skipToNext] and [MediaAction.skipToPrevious] buttons, these + /// additional actions will allow the user to press and hold the buttons to + /// activate the continuous seeking behaviour. + /// + /// On Android, a media notification has a compact and expanded form. In the + /// compact view, you can optionally specify the indices of up to 3 of your + /// [controls] that you would like to be shown. + /// + /// The playback [position] should NOT be updated continuously in real time. + /// Instead, it should be updated only when the normal continuity of time is + /// disrupted, such as during a seek, buffering and seeking. When + /// broadcasting such a position change, the [updateTime] specifies the time + /// of that change, allowing clients to project the realtime value of the + /// position as `position + (DateTime.now() - updateTime)`. As a convenience, + /// this calculation is provided by [PlaybackState.currentPosition]. + /// + /// The playback [speed] is given as a double where 1.0 means normal speed. + static Future setState({ + @required List controls, + List systemActions = const [], + @required AudioProcessingState processingState, + @required bool playing, + Duration position = Duration.zero, + Duration bufferedPosition = Duration.zero, + double speed = 1.0, + Duration updateTime, + List androidCompactActions, + AudioServiceRepeatMode repeatMode = AudioServiceRepeatMode.none, + AudioServiceShuffleMode shuffleMode = AudioServiceShuffleMode.none, + }) async { + _state = PlaybackState( + processingState: processingState, + playing: playing, + actions: controls.map((control) => control.action).toSet(), + position: position, + bufferedPosition: bufferedPosition, + speed: speed, + updateTime: updateTime, + repeatMode: repeatMode, + shuffleMode: shuffleMode, + ); + List rawControls = controls + .map((control) => { + 'androidIcon': control.androidIcon, + 'label': control.label, + 'action': control.action.index, + }) + .toList(); + final rawSystemActions = + systemActions.map((action) => action.index).toList(); + await _backgroundChannel.invokeMethod('setState', [ + rawControls, + rawSystemActions, + processingState.index, + playing, + position.inMilliseconds, + bufferedPosition.inMilliseconds, + speed, + updateTime?.inMilliseconds, + androidCompactActions, + repeatMode.index, + shuffleMode.index, + ]); + } + + /// Sets the current queue and notifies all clients. + static Future setQueue(List queue, + {bool preloadArtwork = false}) async { + _queue = queue; + if (preloadArtwork) { + _loadAllArtwork(queue); + } + await _backgroundChannel.invokeMethod( + 'setQueue', queue.map((item) => item.toJson()).toList()); + } + + /// Sets the currently playing media item and notifies all clients. + static Future setMediaItem(MediaItem mediaItem) async { + _mediaItem = mediaItem; + if (mediaItem.artUri != null) { + // We potentially need to fetch the art. + final fileInfo = _cacheManager.getFileFromMemory(mediaItem.artUri); + String filePath = fileInfo?.file?.path; + if (filePath == null) { + // We haven't fetched the art yet, so show the metadata now, and again + // after we load the art. + await _backgroundChannel.invokeMethod( + 'setMediaItem', mediaItem.toJson()); + // Load the art + filePath = await _loadArtwork(mediaItem); + // If we failed to download the art, abort. + if (filePath == null) return; + // If we've already set a new media item, cancel this request. + if (mediaItem != _mediaItem) return; + } + final extras = Map.of(mediaItem.extras ?? {}); + extras['artCacheFile'] = filePath; + final platformMediaItem = mediaItem.copyWith(extras: extras); + // Show the media item after the art is loaded. + await _backgroundChannel.invokeMethod( + 'setMediaItem', platformMediaItem.toJson()); + } else { + await _backgroundChannel.invokeMethod('setMediaItem', mediaItem.toJson()); + } + } + + static Future _loadAllArtwork(List queue) async { + for (var mediaItem in queue) { + await _loadArtwork(mediaItem); + } + } + + static Future _loadArtwork(MediaItem mediaItem) async { + try { + final artUri = mediaItem.artUri; + if (artUri != null) { + const prefix = 'file://'; + if (artUri.toLowerCase().startsWith(prefix)) { + return artUri.substring(prefix.length); + } else { + final file = await _cacheManager.getSingleFile(mediaItem.artUri); + return file.path; + } + } + } catch (e) {} + return null; + } + + /// Notifies clients that the child media items of [parentMediaId] have + /// changed. + /// + /// If [parentMediaId] is unspecified, the root parent will be used. + static Future notifyChildrenChanged( + [String parentMediaId = AudioService.MEDIA_ROOT_ID]) async { + await _backgroundChannel.invokeMethod( + 'notifyChildrenChanged', parentMediaId); + } + + /// In Android, forces media button events to be routed to your active media + /// session. + /// + /// This is necessary if you want to play TextToSpeech in the background and + /// still respond to media button events. You should call it just before + /// playing TextToSpeech. + /// + /// This is not necessary if you are playing normal audio in the background + /// such as music because this kind of "normal" audio playback will + /// automatically qualify your app to receive media button events. + static Future androidForceEnableMediaButtons() async { + await _backgroundChannel.invokeMethod('androidForceEnableMediaButtons'); + } + + /// Sends a custom event to the Flutter UI. + /// + /// The event parameter can contain any data permitted by Dart's + /// SendPort/ReceivePort API. Please consult the relevant documentation for + /// further information. + static void sendCustomEvent(dynamic event) { + if (!AudioService.usesIsolate) { + AudioService._customEventSubject.add(event); + } else { + SendPort sendPort = + IsolateNameServer.lookupPortByName(_CUSTOM_EVENT_PORT_NAME); + sendPort?.send(event); + } + } +} + +/// An audio task that can run in the background and react to audio events. +/// +/// You should subclass [BackgroundAudioTask] and override the callbacks for +/// each type of event that your background task wishes to react to. At a +/// minimum, you must override [onStart] and [onStop] to handle initialising +/// and shutting down the audio task. +abstract class BackgroundAudioTask { + final BaseCacheManager cacheManager; + Duration _fastForwardInterval; + Duration _rewindInterval; + + /// Subclasses may supply a [cacheManager] to manage the loading of artwork, + /// or an instance of [DefaultCacheManager] will be used by default. + BackgroundAudioTask({BaseCacheManager cacheManager}) + : this.cacheManager = cacheManager ?? DefaultCacheManager(); + + /// The fast forward interval passed into [AudioService.start]. + Duration get fastForwardInterval => _fastForwardInterval; + + /// The rewind interval passed into [AudioService.start]. + Duration get rewindInterval => _rewindInterval; + + /// Called once when this audio task is first started and ready to play + /// audio, in response to [AudioService.start]. [params] will contain any + /// params passed into [AudioService.start] when starting this background + /// audio task. + Future onStart(Map params) async {} + + /// Called when a client has requested to terminate this background audio + /// task, in response to [AudioService.stop]. You should implement this + /// method to stop playing audio and dispose of any resources used. + /// + /// If you override this, make sure your method ends with a call to `await + /// super.onStop()`. The isolate containing this task will shut down as soon + /// as this method completes. + @mustCallSuper + Future onStop() async { + await AudioServiceBackground._shutdown(); + } + + /// Called when a media browser client, such as Android Auto, wants to query + /// the available media items to display to the user. + Future> onLoadChildren(String parentMediaId) async => []; + + /// Called when the media button on the headset is pressed, or in response to + /// a call from [AudioService.click]. The default behaviour is: + /// + /// * On [MediaButton.media], toggle [onPlay] and [onPause]. + /// * On [MediaButton.next], call [onSkipToNext]. + /// * On [MediaButton.previous], call [onSkipToPrevious]. + Future onClick(MediaButton button) async { + switch (button) { + case MediaButton.media: + if (AudioServiceBackground.state?.playing == true) { + await onPause(); + } else { + await onPlay(); + } + break; + case MediaButton.next: + await onSkipToNext(); + break; + case MediaButton.previous: + await onSkipToPrevious(); + break; + } + } + + /// Called when a client has requested to pause audio playback, such as via a + /// call to [AudioService.pause]. You should implement this method to pause + /// audio playback and also broadcast the appropriate state change via + /// [AudioServiceBackground.setState]. + Future onPause() async {} + + /// Called when a client has requested to prepare audio for playback, such as + /// via a call to [AudioService.prepare]. + Future onPrepare() async {} + + /// Called when a client has requested to prepare a specific media item for + /// audio playback, such as via a call to [AudioService.prepareFromMediaId]. + Future onPrepareFromMediaId(String mediaId) async {} + + /// Called when a client has requested to resume audio playback, such as via + /// a call to [AudioService.play]. You should implement this method to play + /// audio and also broadcast the appropriate state change via + /// [AudioServiceBackground.setState]. + Future onPlay() async {} + + /// Called when a client has requested to play a media item by its ID, such + /// as via a call to [AudioService.playFromMediaId]. You should implement + /// this method to play audio and also broadcast the appropriate state change + /// via [AudioServiceBackground.setState]. + Future onPlayFromMediaId(String mediaId) async {} + + /// Called when the Flutter UI has requested to play a given media item via a + /// call to [AudioService.playMediaItem]. You should implement this method to + /// play audio and also broadcast the appropriate state change via + /// [AudioServiceBackground.setState]. + /// + /// Note: This method can only be triggered by your Flutter UI. Peripheral + /// devices such as Android Auto will instead trigger + /// [AudioService.onPlayFromMediaId]. + Future onPlayMediaItem(MediaItem mediaItem) async {} + + /// Called when a client has requested to add a media item to the queue, such + /// as via a call to [AudioService.addQueueItem]. + Future onAddQueueItem(MediaItem mediaItem) async {} + + /// Called when the Flutter UI has requested to set a new queue. + /// + /// If you use a queue, your implementation of this method should call + /// [AudioServiceBackground.setQueue] to notify all clients that the queue + /// has changed. + Future onUpdateQueue(List queue) async {} + + /// Called when the Flutter UI has requested to update the details of + /// a media item. + Future onUpdateMediaItem(MediaItem mediaItem) async {} + + /// Called when a client has requested to add a media item to the queue at a + /// specified position, such as via a request to + /// [AudioService.addQueueItemAt]. + Future onAddQueueItemAt(MediaItem mediaItem, int index) async {} + + /// Called when a client has requested to remove a media item from the queue, + /// such as via a request to [AudioService.removeQueueItem]. + Future onRemoveQueueItem(MediaItem mediaItem) async {} + + /// Called when a client has requested to skip to the next item in the queue, + /// such as via a request to [AudioService.skipToNext]. + /// + /// By default, calls [onSkipToQueueItem] with the queue item after + /// [AudioServiceBackground.mediaItem] if it exists. + Future onSkipToNext() => _skip(1); + + /// Called when a client has requested to skip to the previous item in the + /// queue, such as via a request to [AudioService.skipToPrevious]. + /// + /// By default, calls [onSkipToQueueItem] with the queue item before + /// [AudioServiceBackground.mediaItem] if it exists. + Future onSkipToPrevious() => _skip(-1); + + /// Called when a client has requested to fast forward, such as via a + /// request to [AudioService.fastForward]. An implementation of this callback + /// can use the [fastForwardInterval] property to determine how much audio + /// to skip. + Future onFastForward() async {} + + /// Called when a client has requested to rewind, such as via a request to + /// [AudioService.rewind]. An implementation of this callback can use the + /// [rewindInterval] property to determine how much audio to skip. + Future onRewind() async {} + + /// Called when a client has requested to skip to a specific item in the + /// queue, such as via a call to [AudioService.skipToQueueItem]. + Future onSkipToQueueItem(String mediaId) async {} + + /// Called when a client has requested to seek to a position, such as via a + /// call to [AudioService.seekTo]. If your implementation of seeking causes + /// buffering to occur, consider broadcasting a buffering state via + /// [AudioServiceBackground.setState] while the seek is in progress. + Future onSeekTo(Duration position) async {} + + /// Called when a client has requested to rate the current media item, such as + /// via a call to [AudioService.setRating]. + Future onSetRating(Rating rating, Map extras) async {} + + /// Called when a client has requested to change the current repeat mode. + Future onSetRepeatMode(AudioServiceRepeatMode repeatMode) async {} + + /// Called when a client has requested to change the current shuffle mode. + Future onSetShuffleMode(AudioServiceShuffleMode shuffleMode) async {} + + /// Called when a client has requested to either begin or end seeking + /// backward. + Future onSeekBackward(bool begin) async {} + + /// Called when a client has requested to either begin or end seeking + /// forward. + Future onSeekForward(bool begin) async {} + + /// Called when the Flutter UI has requested to set the speed of audio + /// playback. An implementation of this callback should change the audio + /// speed and broadcast the speed change to all clients via + /// [AudioServiceBackground.setState]. + Future onSetSpeed(double speed) async {} + + /// Called when a custom action has been sent by the client via + /// [AudioService.customAction]. The result of this method will be returned + /// to the client. + Future onCustomAction(String name, dynamic arguments) async {} + + /// Called on Android when the user swipes away your app's task in the task + /// manager. Note that if you use the `androidStopForegroundOnPause` option to + /// [AudioService.start], then when your audio is paused, the operating + /// system moves your service to a lower priority level where it can be + /// destroyed at any time to reclaim memory. If the user swipes away your + /// task under these conditions, the operating system will destroy your + /// service, and you may override this method to do any cleanup. For example: + /// + /// ```dart + /// void onTaskRemoved() { + /// if (!AudioServiceBackground.state.playing) { + /// onStop(); + /// } + /// } + /// ``` + Future onTaskRemoved() async {} + + /// Called on Android when the user swipes away the notification. The default + /// implementation (which you may override) calls [onStop]. + Future onClose() => onStop(); + + void _setParams({ + Duration fastForwardInterval, + Duration rewindInterval, + }) { + _fastForwardInterval = fastForwardInterval; + _rewindInterval = rewindInterval; + } + + Future _skip(int offset) async { + final mediaItem = AudioServiceBackground.mediaItem; + if (mediaItem == null) return; + final queue = AudioServiceBackground.queue ?? []; + int i = queue.indexOf(mediaItem); + if (i == -1) return; + int newIndex = i + offset; + if (newIndex < queue.length) await onSkipToQueueItem(queue[newIndex]?.id); + } +} + +_iosIsolateEntrypoint(int rawHandle) async { + ui.CallbackHandle handle = ui.CallbackHandle.fromRawHandle(rawHandle); + Function backgroundTask = ui.PluginUtilities.getCallbackFromHandle(handle); + backgroundTask(); +} + +/// A widget that maintains a connection to [AudioService]. +/// +/// Insert this widget at the top of your `/` route's widget tree to maintain +/// the connection across all routes. e.g. +/// +/// ``` +/// return MaterialApp( +/// home: AudioServiceWidget(MainScreen()), +/// ); +/// ``` +/// +/// Note that this widget will not work if it wraps around [MateriaApp] itself, +/// you must place it in the widget tree within your route. +class AudioServiceWidget extends StatefulWidget { + final Widget child; + + AudioServiceWidget({@required this.child}); + + @override + _AudioServiceWidgetState createState() => _AudioServiceWidgetState(); +} + +class _AudioServiceWidgetState extends State + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + AudioService.connect(); + } + + @override + void dispose() { + AudioService.disconnect(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + AudioService.connect(); + break; + case AppLifecycleState.paused: + AudioService.disconnect(); + break; + default: + break; + } + } + + @override + Future didPopRoute() async { + AudioService.disconnect(); + return false; + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +enum AudioServiceShuffleMode { none, all, group } + +enum AudioServiceRepeatMode { none, one, all, group } diff --git a/lib/audio_service_web.dart b/lib/audio_service_web.dart new file mode 100644 index 0000000..3c51dbd --- /dev/null +++ b/lib/audio_service_web.dart @@ -0,0 +1,354 @@ +import 'dart:async'; +import 'dart:html' as html; +import 'dart:js' as js; +import 'package:audio_service/js/media_metadata.dart'; + +import 'js/media_session_web.dart'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; + +const String _CUSTOM_PREFIX = 'custom_'; + +class Art { + String src; + String type; + String sizes; + Art({this.src, this.type, this.sizes}); +} + +class AudioServicePlugin { + int fastForwardInterval; + int rewindInterval; + Map params; + bool started = false; + ClientHandler clientHandler; + BackgroundHandler backgroundHandler; + + static void registerWith(Registrar registrar) { + AudioServicePlugin(registrar); + } + + AudioServicePlugin(Registrar registrar) { + clientHandler = ClientHandler(this, registrar); + backgroundHandler = BackgroundHandler(this, registrar); + } +} + +class ClientHandler { + final AudioServicePlugin plugin; + final MethodChannel channel; + + ClientHandler(this.plugin, Registrar registrar) + : channel = MethodChannel( + 'ryanheise.com/audioService', + const StandardMethodCodec(), + registrar.messenger, + ) { + channel.setMethodCallHandler(handleServiceMethodCall); + } + + Future invokeMethod(String method, [dynamic arguments]) => + channel.invokeMethod(method, arguments); + + Future handleServiceMethodCall(MethodCall call) async { + switch (call.method) { + case 'start': + plugin.fastForwardInterval = call.arguments['fastForwardInterval']; + plugin.rewindInterval = call.arguments['rewindInterval']; + plugin.params = call.arguments['params']; + plugin.started = true; + return plugin.started; + case 'connect': + // No-op not really anything for us to do with connect on the web, the + // streams should all be hydrated + break; + case 'disconnect': + // No-op not really anything for us to do with disconnect on the web, + // the streams should stay hydrated because everything is static and we + // aren't working with isolates + break; + case 'isRunning': + return plugin.started; + case 'rewind': + return plugin.backgroundHandler.invokeMethod('onRewind'); + case 'fastForward': + return plugin.backgroundHandler.invokeMethod('onFastForward'); + case 'skipToPrevious': + return plugin.backgroundHandler.invokeMethod('onSkipToPrevious'); + case 'skipToNext': + return plugin.backgroundHandler.invokeMethod('onSkipToNext'); + case 'play': + return plugin.backgroundHandler.invokeMethod('onPlay'); + case 'pause': + return plugin.backgroundHandler.invokeMethod('onPause'); + case 'stop': + return plugin.backgroundHandler.invokeMethod('onStop'); + case 'seekTo': + return plugin.backgroundHandler + .invokeMethod('onSeekTo', [call.arguments]); + case 'prepareFromMediaId': + return plugin.backgroundHandler + .invokeMethod('onPrepareFromMediaId', [call.arguments]); + case 'playFromMediaId': + return plugin.backgroundHandler + .invokeMethod('onPlayFromMediaId', [call.arguments]); + case 'setBrowseMediaParent': + return plugin.backgroundHandler + .invokeMethod('onLoadChildren', [call.arguments]); + case 'onClick': + // No-op we don't really have the idea of a bluetooth button click on + // the web + break; + case 'addQueueItem': + return plugin.backgroundHandler + .invokeMethod('onAddQueueItem', [call.arguments]); + case 'addQueueItemAt': + return plugin.backgroundHandler + .invokeMethod('onQueueItemAt', call.arguments); + case 'removeQueueItem': + return plugin.backgroundHandler + .invokeMethod('onRemoveQueueItem', [call.arguments]); + case 'updateQueue': + return plugin.backgroundHandler + .invokeMethod('onUpdateQueue', [call.arguments]); + case 'updateMediaItem': + return plugin.backgroundHandler + .invokeMethod('onUpdateMediaItem', [call.arguments]); + case 'prepare': + return plugin.backgroundHandler.invokeMethod('onPrepare'); + case 'playMediaItem': + return plugin.backgroundHandler + .invokeMethod('onPlayMediaItem', [call.arguments]); + case 'skipToQueueItem': + return plugin.backgroundHandler + .invokeMethod('onSkipToMediaItem', [call.arguments]); + case 'setRepeatMode': + return plugin.backgroundHandler + .invokeMethod('onSetRepeatMode', [call.arguments]); + case 'setShuffleMode': + return plugin.backgroundHandler + .invokeMethod('onSetShuffleMode', [call.arguments]); + case 'setRating': + return plugin.backgroundHandler.invokeMethod('onSetRating', + [call.arguments['rating'], call.arguments['extras']]); + case 'setSpeed': + return plugin.backgroundHandler + .invokeMethod('onSetSpeed', [call.arguments]); + default: + if (call.method.startsWith(_CUSTOM_PREFIX)) { + final result = await plugin.backgroundHandler + .invokeMethod(call.method, call.arguments); + return result; + } + throw PlatformException( + code: 'Unimplemented', + details: "The audio Service plugin for web doesn't implement " + "the method '${call.method}'"); + } + } +} + +class BackgroundHandler { + final AudioServicePlugin plugin; + final MethodChannel channel; + MediaItem mediaItem; + + BackgroundHandler(this.plugin, Registrar registrar) + : channel = MethodChannel( + 'ryanheise.com/audioServiceBackground', + const StandardMethodCodec(), + registrar.messenger, + ) { + channel.setMethodCallHandler(handleBackgroundMethodCall); + } + + Future invokeMethod(String method, [dynamic arguments]) => + channel.invokeMethod(method, arguments); + + Future handleBackgroundMethodCall(MethodCall call) async { + switch (call.method) { + case 'started': + return started(call); + case 'ready': + return ready(call); + case 'stopped': + return stopped(call); + case 'setState': + return setState(call); + case 'setMediaItem': + return setMediaItem(call); + case 'setQueue': + return setQueue(call); + case 'androidForceEnableMediaButtons': + //no-op + break; + default: + throw PlatformException( + code: 'Unimplemented', + details: + "The audio service background plugin for web doesn't implement " + "the method '${call.method}'"); + } + } + + Future started(MethodCall call) async => true; + + Future ready(MethodCall call) async => { + 'fastForwardInterval': plugin.fastForwardInterval ?? 30000, + 'rewindInterval': plugin.rewindInterval ?? 30000, + 'params': plugin.params + }; + + Future stopped(MethodCall call) async { + final session = html.window.navigator.mediaSession; + session.metadata = null; + plugin.started = false; + mediaItem = null; + plugin.clientHandler.invokeMethod('onStopped'); + } + + Future setState(MethodCall call) async { + final session = html.window.navigator.mediaSession; + final List args = call.arguments; + final List controls = call.arguments[0] + .map((element) => MediaControl( + action: MediaAction.values[element['action']], + androidIcon: element['androidIcon'], + label: element['label'])) + .toList(); + + // Reset the handlers + // TODO: Make this better... Like only change ones that have been changed + try { + session.setActionHandler('play', null); + session.setActionHandler('pause', null); + session.setActionHandler('previoustrack', null); + session.setActionHandler('nexttrack', null); + session.setActionHandler('seekbackward', null); + session.setActionHandler('seekforward', null); + session.setActionHandler('stop', null); + } catch (e) {} + + int actionBits = 0; + for (final control in controls) { + try { + switch (control.action) { + case MediaAction.play: + session.setActionHandler('play', AudioService.play); + break; + case MediaAction.pause: + session.setActionHandler('pause', AudioService.pause); + break; + case MediaAction.skipToPrevious: + session.setActionHandler( + 'previoustrack', AudioService.skipToPrevious); + break; + case MediaAction.skipToNext: + session.setActionHandler('nexttrack', AudioService.skipToNext); + break; + // The naming convention here is a bit odd but seekbackward seems more + // analagous to rewind than seekBackward + case MediaAction.rewind: + session.setActionHandler('seekbackward', AudioService.rewind); + break; + case MediaAction.fastForward: + session.setActionHandler('seekforward', AudioService.fastForward); + break; + case MediaAction.stop: + session.setActionHandler('stop', AudioService.stop); + break; + default: + // no-op + break; + } + } catch (e) {} + int actionCode = 1 << control.action.index; + actionBits |= actionCode; + } + + for (int rawSystemAction in call.arguments[1]) { + MediaAction action = MediaAction.values[rawSystemAction]; + + switch (action) { + case MediaAction.seekTo: + try { + setActionHandler('seekto', js.allowInterop((ActionResult ev) { + print(ev.action); + print(ev.seekTime); + // Chrome uses seconds for whatever reason + AudioService.seekTo(Duration( + milliseconds: (ev.seekTime * 1000).round(), + )); + })); + } catch (e) {} + break; + default: + // no-op + break; + } + + int actionCode = 1 << rawSystemAction; + actionBits |= actionCode; + } + + try { + // Dart also doesn't expose setPositionState + if (mediaItem != null) { + print( + 'Setting positionState Duration(${mediaItem.duration.inSeconds}), PlaybackRate(${args[6] ?? 1.0}), Position(${Duration(milliseconds: args[4]).inSeconds})'); + + // Chrome looks for seconds for some reason + setPositionState(PositionState( + duration: (mediaItem.duration?.inMilliseconds ?? 0) / 1000, + playbackRate: args[6] ?? 1.0, + position: (args[4] ?? 0) / 1000, + )); + } + } catch (e) { + print(e); + } + + plugin.clientHandler.invokeMethod('onPlaybackStateChanged', [ + args[2], // Processing state + args[3], // Playing + actionBits, // Action bits + args[4], // Position + args[5], // bufferedPosition + args[6] ?? 1.0, // speed + args[7] ?? DateTime.now().millisecondsSinceEpoch, // updateTime + args[9], // repeatMode + args[10], // shuffleMode + ]); + } + + Future setMediaItem(MethodCall call) async { + mediaItem = MediaItem.fromJson(call.arguments); + // This would be how we could pull images out of the cache... But nothing is actually cached on web + final artUri = /* mediaItem.extras['artCacheFile'] ?? */ + mediaItem.artUri; + + try { + metadata = MediaMetadata(MetadataLiteral( + album: mediaItem.album, + title: mediaItem.title, + artist: mediaItem.artist, + artwork: [ + MetadataArtwork( + src: artUri, + sizes: '512x512', + ) + ], + )); + } catch (e) { + print('Metadata failed $e'); + } + + plugin.clientHandler.invokeMethod('onMediaChanged', [mediaItem.toJson()]); + } + + Future setQueue(MethodCall call) async { + plugin.clientHandler.invokeMethod('onQueueChanged', [call.arguments]); + } +} diff --git a/lib/js/media_metadata.dart b/lib/js/media_metadata.dart new file mode 100644 index 0000000..157f646 --- /dev/null +++ b/lib/js/media_metadata.dart @@ -0,0 +1,32 @@ +@JS() +library media_metadata; + +import 'package:js/js.dart'; + +@JS('MediaMetadata') +class MediaMetadata { + external MediaMetadata(MetadataLiteral md); +} + +@JS() +@anonymous +class MetadataLiteral { + external String get title; + external String get album; + external String get artist; + external List get artwork; + external factory MetadataLiteral( + {String title, + String album, + String artist, + List artwork}); +} + +@JS() +@anonymous +class MetadataArtwork { + external String get src; + external String get sizes; + external String get type; + external factory MetadataArtwork({String src, String sizes, String type}); +} diff --git a/lib/js/media_session_web.dart b/lib/js/media_session_web.dart new file mode 100644 index 0000000..1cdd287 --- /dev/null +++ b/lib/js/media_session_web.dart @@ -0,0 +1,36 @@ +@JS('navigator.mediaSession') +library media_session_web; + +import 'package:js/js.dart'; +import 'media_metadata.dart'; + +@JS('setActionHandler') +external void setActionHandler(String action, Function(ActionResult) callback); + +@JS('setPositionState') +external void setPositionState(PositionState state); + +@JS() +@anonymous +class ActionResult { + external String get action; + external double get seekTime; + + external factory ActionResult({String action, double seekTime}); +} + +@JS() +@anonymous +class PositionState { + external double get duration; + external double get playbackRate; + external double get position; + external factory PositionState({ + double duration, + double playbackRate, + double position, + }); +} + +@JS('metadata') +external set metadata(MediaMetadata metadata); diff --git a/macos/Classes/AudioServicePlugin.h b/macos/Classes/AudioServicePlugin.h new file mode 100644 index 0000000..a07fa79 --- /dev/null +++ b/macos/Classes/AudioServicePlugin.h @@ -0,0 +1,54 @@ +#import + +@interface AudioServicePlugin : NSObject +@end + +enum AudioProcessingState { + none, + connecting, + ready, + buffering, + fastForwarding, + rewinding, + skippingToPrevious, + skippingToNext, + skippingToQueueItem, + completed, + stopped, + error +}; + +enum AudioInterruption { + AIPause, + AITemporaryPause, + AITemporaryDuck, + AIUnknownPause +}; + +enum MediaAction { + AStop, + APause, + APlay, + ARewind, + ASkipToPrevious, + ASkipToNext, + AFastForward, + ASetRating, + ASeekTo, + APlayPause, + APlayFromMediaId, + APlayFromSearch, + ASkipToQueueItem, + APlayFromUri, + APrepare, + APrepareFromMediaId, + APrepareFromSearch, + APrepareFromUri, + ASetRepeatMode, + AUnused_1, // deprecated (setShuffleModeEnabled) + AUnused_2, // setCaptioningEnabled + ASetShuffleMode, + // Non-standard + ASeekBackward, + ASeekForward, +}; diff --git a/macos/Classes/AudioServicePlugin.m b/macos/Classes/AudioServicePlugin.m new file mode 100644 index 0000000..48e8463 --- /dev/null +++ b/macos/Classes/AudioServicePlugin.m @@ -0,0 +1,617 @@ +#import "AudioServicePlugin.h" +#import +#import + +// 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! + +@implementation AudioServicePlugin + +static FlutterMethodChannel *channel = nil; +static FlutterMethodChannel *backgroundChannel = nil; +static BOOL _running = NO; +static FlutterResult startResult = nil; +static MPRemoteCommandCenter *commandCenter = nil; +static NSArray *queue = nil; +static NSMutableDictionary *mediaItem = nil; +static long actionBits; +static NSArray *commands; +static BOOL _controlsUpdated = NO; +static enum AudioProcessingState processingState = none; +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 NSMutableDictionary *params = nil; +static MPMediaItemArtwork* artwork = nil; + ++ (void)registerWithRegistrar:(NSObject*)registrar { + @synchronized(self) { + // TODO: Need a reliable way to detect whether this is the client + // or background. + // TODO: Handle multiple clients. + // As no separate isolate is used on macOS, add both handlers to the one registrar. +#if TARGET_OS_IPHONE + if (channel == nil) { +#endif + AudioServicePlugin *instance = [[AudioServicePlugin alloc] init:registrar]; + channel = [FlutterMethodChannel + methodChannelWithName:@"ryanheise.com/audioService" + binaryMessenger:[registrar messenger]]; + [registrar addMethodCallDelegate:instance channel:channel]; +#if TARGET_OS_IPHONE + } else { + AudioServicePlugin *instance = [[AudioServicePlugin alloc] init:registrar]; +#endif + backgroundChannel = [FlutterMethodChannel + methodChannelWithName:@"ryanheise.com/audioServiceBackground" + binaryMessenger:[registrar messenger]]; + [registrar addMethodCallDelegate:instance channel:backgroundChannel]; +#if TARGET_OS_IPHONE + } +#endif + } +} + +- (instancetype)init:(NSObject *)registrar { + self = [super init]; + NSAssert(self, @"super init cannot be nil"); + return self; +} + +- (void)broadcastPlaybackState { + [channel invokeMethod:@"onPlaybackStateChanged" arguments:@[ + // processingState + @(processingState), + // playing + @(playing), + // actions + @(actionBits), + // position + position, + // bufferedPosition + bufferedPosition, + // playback speed + speed, + // update time since epoch + updateTime, + // repeat mode + repeatMode, + // shuffle mode + shuffleMode, + ]]; +} + +- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + // TODO: + // - Restructure this so that we have a separate method call delegate + // for the client instance and the background instance so that methods + // can't be called on the wrong instance. + if ([@"connect" isEqualToString:call.method]) { + long long msSinceEpoch = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); + if (position == nil) { + position = @(0); + bufferedPosition = @(0); + updateTime = [NSNumber numberWithLongLong: msSinceEpoch]; + speed = [NSNumber numberWithDouble: 1.0]; + repeatMode = @(0); + shuffleMode = @(0); + } + // Notify client of state on subscribing. + [self broadcastPlaybackState]; + [channel invokeMethod:@"onMediaChanged" arguments:@[mediaItem ? mediaItem : [NSNull null]]]; + [channel invokeMethod:@"onQueueChanged" arguments:@[queue ? queue : [NSNull null]]]; + + result(nil); + } else if ([@"disconnect" isEqualToString:call.method]) { + result(nil); + } else if ([@"start" isEqualToString:call.method]) { + if (_running) { + result(@NO); + return; + } + _running = YES; + // The result will be sent after the background task actually starts. + // See the "ready" case below. + startResult = result; + +#if TARGET_OS_IPHONE + [AVAudioSession sharedInstance]; +#endif + + // Set callbacks on MPRemoteCommandCenter + fastForwardInterval = [call.arguments objectForKey:@"fastForwardInterval"]; + rewindInterval = [call.arguments objectForKey:@"rewindInterval"]; + commandCenter = [MPRemoteCommandCenter sharedCommandCenter]; + commands = @[ + commandCenter.stopCommand, + commandCenter.pauseCommand, + commandCenter.playCommand, + commandCenter.skipBackwardCommand, + commandCenter.previousTrackCommand, + commandCenter.nextTrackCommand, + commandCenter.skipForwardCommand, + [NSNull null], + commandCenter.changePlaybackPositionCommand, + 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 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]; + [self updateControls]; + + // Params + params = [call.arguments objectForKey:@"params"]; + +#if TARGET_OS_OSX + // No isolate can be used for macOS until https://github.com/flutter/flutter/issues/65222 is resolved. + // We send a result here, and then the Dart code continues in the main isolate. + result(@YES); +#endif + } else if ([@"ready" isEqualToString:call.method]) { + NSMutableDictionary *startParams = [NSMutableDictionary new]; + startParams[@"fastForwardInterval"] = fastForwardInterval; + startParams[@"rewindInterval"] = rewindInterval; + startParams[@"params"] = params; + result(startParams); + } else if ([@"started" isEqualToString:call.method]) { +#if TARGET_OS_IPHONE + if (startResult) { + startResult(@YES); + startResult = nil; + } +#endif + result(@YES); + } else if ([@"stopped" isEqualToString:call.method]) { + _running = NO; + [channel invokeMethod:@"onStopped" arguments:nil]; + [commandCenter.changePlaybackRateCommand setEnabled:NO]; + [commandCenter.togglePlayPauseCommand setEnabled:NO]; + [commandCenter.togglePlayPauseCommand removeTarget:nil]; + [MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = nil; + processingState = none; + playing = NO; + position = nil; + bufferedPosition = nil; + updateTime = nil; + speed = nil; + artwork = nil; + mediaItem = nil; + repeatMode = @(0); + shuffleMode = @(0); + actionBits = 0; + [self updateControls]; + _controlsUpdated = NO; + queue = nil; + startResult = nil; + fastForwardInterval = nil; + rewindInterval = nil; + params = nil; + commandCenter = nil; + result(@YES); + } else if ([@"isRunning" isEqualToString:call.method]) { + if (_running) { + result(@YES); + } else { + result(@NO); + } + } else if ([@"setBrowseMediaParent" isEqualToString:call.method]) { + result(@YES); + } else if ([@"addQueueItem" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onAddQueueItem" arguments:@[call.arguments] result: result]; + } else if ([@"addQueueItemAt" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onAddQueueItemAt" arguments:call.arguments result: result]; + } else if ([@"removeQueueItem" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onRemoveQueueItem" arguments:@[call.arguments] result: result]; + } else if ([@"updateQueue" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onUpdateQueue" arguments:@[call.arguments] result: result]; + } else if ([@"updateMediaItem" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onUpdateMediaItem" arguments:@[call.arguments] result: result]; + } else if ([@"click" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onClick" arguments:@[call.arguments] result: result]; + } else if ([@"prepare" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPrepare" arguments:nil result: result]; + } else if ([@"prepareFromMediaId" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPrepareFromMediaId" arguments:@[call.arguments] result: result]; + } else if ([@"play" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPlay" arguments:nil result: result]; + } else if ([@"playFromMediaId" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPlayFromMediaId" arguments:@[call.arguments] result: result]; + } else if ([@"playMediaItem" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPlayMediaItem" arguments:@[call.arguments] result: result]; + } else if ([@"skipToQueueItem" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSkipToQueueItem" arguments:@[call.arguments] result: result]; + } else if ([@"pause" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onPause" arguments:nil result: result]; + } else if ([@"stop" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onStop" arguments:nil result: result]; + } else if ([@"seekTo" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSeekTo" arguments:@[call.arguments] result: result]; + } else if ([@"skipToNext" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSkipToNext" arguments:nil result: result]; + } else if ([@"skipToPrevious" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSkipToPrevious" arguments:nil result: result]; + } else if ([@"fastForward" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onFastForward" arguments:nil result: result]; + } else if ([@"rewind" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onRewind" arguments:nil result: result]; + } else if ([@"setRepeatMode" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSetRepeatMode" arguments:@[call.arguments] result: result]; + } else if ([@"setShuffleMode" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSetShuffleMode" arguments:@[call.arguments] result: result]; + } else if ([@"setRating" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSetRating" arguments:@[call.arguments[@"rating"], call.arguments[@"extras"]] result: result]; + } else if ([@"setSpeed" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSetSpeed" arguments:@[call.arguments] result: result]; + } else if ([@"seekForward" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSeekForward" arguments:@[call.arguments] result: result]; + } else if ([@"seekBackward" isEqualToString:call.method]) { + [backgroundChannel invokeMethod:@"onSeekBackward" arguments:@[call.arguments] result: result]; + } else if ([@"setState" isEqualToString:call.method]) { + long long msSinceEpoch; + if (call.arguments[7] != [NSNull null]) { + msSinceEpoch = [call.arguments[7] longLongValue]; + } else { + msSinceEpoch = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); + } + actionBits = 0; + NSArray *controlsArray = call.arguments[0]; + 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 = call.arguments[1]; + for (int i = 0; i < systemActionsArray.count; i++) { + NSNumber *actionIndex = (NSNumber *)systemActionsArray[i]; + int actionCode = 1 << [actionIndex intValue]; + actionBits |= actionCode; + } + processingState = [call.arguments[2] intValue]; + playing = [call.arguments[3] boolValue]; + position = call.arguments[4]; + bufferedPosition = call.arguments[5]; + speed = call.arguments[6]; + repeatMode = call.arguments[9]; + shuffleMode = call.arguments[10]; + updateTime = [NSNumber numberWithLongLong: msSinceEpoch]; + [self broadcastPlaybackState]; + [self updateControls]; + [self updateNowPlayingInfo]; + result(@(YES)); + } else if ([@"setQueue" isEqualToString:call.method]) { + queue = call.arguments; + [channel invokeMethod:@"onQueueChanged" arguments:@[queue]]; + result(@YES); + } else if ([@"setMediaItem" isEqualToString:call.method]) { + mediaItem = call.arguments; + NSString* artUri = mediaItem[@"artUri"]; + artwork = nil; + if (![artUri isEqual: [NSNull null]]) { + NSString* artCacheFilePath = [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]; + [channel invokeMethod:@"onMediaChanged" arguments:@[call.arguments]]; + result(@(YES)); + } else if ([@"notifyChildrenChanged" isEqualToString:call.method]) { + result(@YES); + } else if ([@"androidForceEnableMediaButtons" isEqualToString:call.method]) { + result(@YES); + } else { + // TODO: Check if this implementation is correct. + // Can I just pass on the result as the last argument? + [backgroundChannel invokeMethod:call.method arguments:call.arguments result: result]; + } +} + +- (MPRemoteCommandHandlerStatus) play: (MPRemoteCommandEvent *) event { + NSLog(@"play"); + [backgroundChannel invokeMethod:@"onPlay" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) pause: (MPRemoteCommandEvent *) event { + NSLog(@"pause"); + [backgroundChannel invokeMethod:@"onPause" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (void) updateNowPlayingInfo { + NSMutableDictionary *nowPlayingInfo = [NSMutableDictionary new]; + if (mediaItem) { + nowPlayingInfo[MPMediaItemPropertyTitle] = mediaItem[@"title"]; + nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = mediaItem[@"album"]; + if (mediaItem[@"artist"] != [NSNull null]) { + nowPlayingInfo[MPMediaItemPropertyArtist] = mediaItem[@"artist"]; + } + if (mediaItem[@"duration"] != [NSNull null]) { + nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = [NSNumber numberWithLongLong: ([mediaItem[@"duration"] longLongValue] / 1000)]; + } + if (@available(iOS 3.0, macOS 10.13.2, *)) { + if (artwork) { + nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork; + } + } + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = [NSNumber numberWithInt:([position intValue] / 1000)]; + } + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = [NSNumber numberWithDouble: playing ? 1.0 : 0.0]; + [MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = nowPlayingInfo; +} + +- (void) updateControls { + for (enum MediaAction action = AStop; action <= ASeekForward; action++) { + [self updateControl:action]; + } + _controlsUpdated = YES; +} + +- (void) updateControl:(enum MediaAction)action { + MPRemoteCommand *command = commands[action]; + if (command == [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; + [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]; + } + } + 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; + } +} + +- (MPRemoteCommandHandlerStatus) togglePlayPause: (MPRemoteCommandEvent *) event { + NSLog(@"togglePlayPause"); + [backgroundChannel invokeMethod:@"onClick" arguments:@[@(0)]]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) stop: (MPRemoteCommandEvent *) event { + NSLog(@"stop"); + [backgroundChannel invokeMethod:@"onStop" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) nextTrack: (MPRemoteCommandEvent *) event { + NSLog(@"nextTrack"); + [backgroundChannel invokeMethod:@"onSkipToNext" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) previousTrack: (MPRemoteCommandEvent *) event { + NSLog(@"previousTrack"); + [backgroundChannel invokeMethod:@"onSkipToPrevious" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) changePlaybackPosition: (MPChangePlaybackPositionCommandEvent *) event { + NSLog(@"changePlaybackPosition"); + [backgroundChannel invokeMethod:@"onSeekTo" arguments: @[@((long long) (event.positionTime * 1000))]]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) skipForward: (MPRemoteCommandEvent *) event { + NSLog(@"skipForward"); + [backgroundChannel invokeMethod:@"onFastForward" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) skipBackward: (MPRemoteCommandEvent *) event { + NSLog(@"skipBackward"); + [backgroundChannel invokeMethod:@"onRewind" arguments:nil]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) seekForward: (MPSeekCommandEvent *) event { + NSLog(@"seekForward"); + BOOL begin = event.type == MPSeekCommandEventTypeBeginSeeking; + [backgroundChannel invokeMethod:@"onSeekForward" arguments:@[@(begin)]]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) seekBackward: (MPSeekCommandEvent *) event { + NSLog(@"seekBackward"); + BOOL begin = event.type == MPSeekCommandEventTypeBeginSeeking; + [backgroundChannel invokeMethod:@"onSeekBackward" arguments:@[@(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; + } + [backgroundChannel invokeMethod:@"onSetRepeatMode" arguments:@[@(modeIndex)]]; + 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; + } + [backgroundChannel invokeMethod:@"onSetShuffleMode" arguments:@[@(modeIndex)]]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (void) dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +@end diff --git a/macos/audio_service.podspec b/macos/audio_service.podspec new file mode 100644 index 0000000..5577dd7 --- /dev/null +++ b/macos/audio_service.podspec @@ -0,0 +1,22 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint audio_service.podspec' to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'audio_service' + s.version = '0.14.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 = 'Classes/**/*' + s.dependency 'FlutterMacOS' + + s.platform = :osx, '10.12.2' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.0' +end diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..f8112fb --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,34 @@ +name: audio_service +description: Flutter plugin to play audio in the background while the screen is off. +version: 0.15.0 +homepage: https://github.com/ryanheise/audio_service + +environment: + sdk: ">=2.7.0 <3.0.0" + flutter: ">=1.12.13+hotfix.5" + +dependencies: + audio_session: ^0.0.5 + rxdart: ^0.24.1 + flutter_isolate: ^1.0.0+14 + flutter_cache_manager: ^1.4.0 + js: ^0.6.2 + + flutter: + sdk: flutter + flutter_web_plugins: + sdk: flutter + +flutter: + plugin: + platforms: + android: + package: com.ryanheise.audioservice + pluginClass: AudioServicePlugin + ios: + pluginClass: AudioServicePlugin + macos: + pluginClass: AudioServicePlugin + web: + pluginClass: AudioServicePlugin + fileName: audio_service_web.dart