diff --git a/.gitattributes b/.gitattributes index a52f8301..3247dbf0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -14,6 +14,13 @@ android/app/src/test/resources/lyrics_usability_cases.tsv whitespace=-blank-at-e # Retain upstream dependency sources for the Android compatibility patch. rust_backend/vendor/** linguist-vendored +third_party/audio_service/** linguist-vendored +# Keep the pinned package byte-for-byte outside the documented native patch. +third_party/audio_service/CHANGELOG.md whitespace=-blank-at-eol +third_party/audio_service/README.md whitespace=-blank-at-eol +third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java whitespace=-blank-at-eol +third_party/audio_service/darwin/audio_service/Sources/audio_service/AudioServicePlugin.m whitespace=-blank-at-eol +third_party/audio_service/darwin/audio_service.podspec whitespace=-blank-at-eof # Preserve the original whitespace in upstream license and policy documents. rust_backend/vendor/rustix-1.1.4/LICENSE-Apache-2.0_WITH_LLVM-exception whitespace=-blank-at-eof rust_backend/vendor/rustix-1.1.4/ORG_CODE_OF_CONDUCT.md whitespace=-blank-at-eol diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 115d9e71..627080a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,7 @@ jobs: with: filters: | dart: + - 'third_party/audio_service/**' - 'lib/**' - 'test/**' - 'android/app/src/test/resources/**' @@ -48,6 +49,7 @@ jobs: - 'android/gradle/wrapper/**' - '.github/workflows/ci.yml' ios: + - 'third_party/audio_service/**' - 'ios/**' - 'scripts/build_ios.sh' - 'scripts/check_backend_ios.py' @@ -60,6 +62,7 @@ jobs: - '.fvmrc' - '.github/workflows/ci.yml' android: + - 'third_party/audio_service/**' - 'android/**' - 'rust_backend/**' - 'scripts/build_android.sh' diff --git a/analysis_options.yaml b/analysis_options.yaml index ae4559b3..19f9ebfe 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -16,6 +16,7 @@ analyzer: - windows/** - macos/** - linux/** + - third_party/audio_service/** language: strict-casts: true strict-inference: true diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index db0024c4..fa41a31e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -35,6 +35,11 @@ android:networkSecurityConfig="@xml/network_security_config" android:enableOnBackInvokedCallback="true" android:localeConfig="@xml/locale_config"> + = 34 && + MediaRouter2.getInstance(this).showSystemOutputSwitcher()) { + return + } + // Public output-switcher API is unavailable on older Android. + startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) + } catch (_: Exception) { + // Do not leave a transparent activity over the notification shade. + } + finish() + } +} diff --git a/android/app/src/main/res/drawable/ic_notification_airplay.xml b/android/app/src/main/res/drawable/ic_notification_airplay.xml new file mode 100644 index 00000000..4f3ecc0f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification_airplay.xml @@ -0,0 +1,9 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_notification_star.xml b/android/app/src/main/res/drawable/ic_notification_star.xml new file mode 100644 index 00000000..1e59e80c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification_star.xml @@ -0,0 +1,7 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_notification_star_filled.xml b/android/app/src/main/res/drawable/ic_notification_star_filled.xml new file mode 100644 index 00000000..86038215 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification_star_filled.xml @@ -0,0 +1,6 @@ + + + diff --git a/android/app/src/main/res/raw/notification_keep.xml b/android/app/src/main/res/raw/notification_keep.xml new file mode 100644 index 00000000..36ad4d2f --- /dev/null +++ b/android/app/src/main/res/raw/notification_keep.xml @@ -0,0 +1,2 @@ + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 4a913b91..249cc928 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -24,6 +24,7 @@ import UniformTypeIdentifiers private var libraryScanProgressGeneration: UInt64 = 0 private var backendChannel: FlutterMethodChannel? private var concertCalendar: ConcertCalendarBridge? + private var playbackNotification: PlaybackNotificationBridge? private let coreBackend: CoreBackend = createCoreBackend() private var pendingSessionGrantEvents: [[String: Any]] = [] @@ -110,6 +111,7 @@ import UniformTypeIdentifiers ) GeneratedPluginRegistrant.register(with: registry) + playbackNotification = PlaybackNotificationBridge(messenger: messenger) registry.registrar(forPlugin: "AudioOutputView")?.register( AudioOutputViewFactory(messenger: messenger), withId: "com.zarz.spotiflac/audio_output" diff --git a/ios/Runner/AudioOutputView.swift b/ios/Runner/AudioOutputView.swift index ceb57c93..17fa6cca 100644 --- a/ios/Runner/AudioOutputView.swift +++ b/ios/Runner/AudioOutputView.swift @@ -1,7 +1,41 @@ import AVKit import Flutter +import MediaPlayer import UIKit +/// iOS owns the lock-screen layout, transport glyphs and AirPlay button. +/// Supply its favorite command with the same state/action as the Mornye player. +final class PlaybackNotificationBridge { + private let channel: FlutterMethodChannel + private var target: Any? + + init(messenger: FlutterBinaryMessenger) { + channel = FlutterMethodChannel(name: "com.zarz.spotiflac/playback_notification", binaryMessenger: messenger) + let command = MPRemoteCommandCenter.shared().likeCommand + target = command.addTarget { [weak self] _ in + guard let self else { return .commandFailed } + self.channel.invokeMethod("favorite", arguments: nil) + return .success + } + command.isEnabled = false + channel.setMethodCallHandler { call, result in + guard call.method == "update", let values = call.arguments as? [String: Any] else { + result(FlutterMethodNotImplemented) + return + } + command.isEnabled = values["enabled"] as? Bool ?? false + command.isActive = values["loved"] as? Bool ?? false + command.localizedTitle = values["label"] as? String ?? "Favorite" + result(nil) + } + } + + deinit { + if let target { MPRemoteCommandCenter.shared().likeCommand.removeTarget(target) } + channel.setMethodCallHandler(nil) + } +} + final class AudioOutputViewFactory: NSObject, FlutterPlatformViewFactory { private let messenger: FlutterBinaryMessenger diff --git a/lib/providers/playback_notification_provider.dart b/lib/providers/playback_notification_provider.dart new file mode 100644 index 00000000..d1ec50e0 --- /dev/null +++ b/lib/providers/playback_notification_provider.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/models/theme_settings.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/theme_provider.dart'; +import 'package:spotiflac_android/services/music_player_service.dart'; +import 'package:spotiflac_android/services/playback_notification.dart'; + +/// Kept alive by the app shell so notification favorites share the exact +/// Library identity and refresh when the same track is starred inside the app. +final playbackNotificationProvider = Provider.autoDispose + .family(( + ref, + labels, + ) { + final mornye = ref.watch( + themeProvider.select((theme) => theme.style == AppThemeStyle.mornye), + ); + final item = ref.watch(currentMediaItemProvider).value; + final track = item == null + ? null + : ref.watch(playerCollectionTrackProvider(item)).value; + final loved = ref.watch( + libraryCollectionsProvider.select( + (state) => track != null && state.isLoved(track), + ), + ); + // Publishing playback state inside a provider build would synchronously + // modify the stream provider this binding is observing. + var active = true; + ref.onDispose(() => active = false); + scheduleMicrotask(() { + if (!active) return; + configurePlaybackNotification( + presentation: PlaybackNotification( + mornye: mornye, + mediaId: item?.id, + source: item?.extras?['source']?.toString(), + loved: loved, + favoriteLabel: loved ? labels.unfavorite : labels.favorite, + outputLabel: labels.output, + ), + toggleFavorite: (selected) async { + final collections = ref.read(libraryCollectionsProvider.notifier); + final selectedTrack = await ref.read( + playerCollectionTrackProvider(selected).future, + ); + await collections.toggleLoved(selectedTrack); + }, + ); + }); + }); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 29b5f106..43f30980 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -14,6 +14,7 @@ import 'package:spotiflac_android/screens/upgrade_intro_screen.dart'; import 'package:spotiflac_android/services/upgrade_intro_service.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/playback_notification_provider.dart'; import 'package:spotiflac_android/providers/repo_provider.dart'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; @@ -721,6 +722,13 @@ class _MainShellState extends ConsumerState @override Widget build(BuildContext context) { + ref.watch( + playbackNotificationProvider(( + favorite: context.l10n.trackOptionAddToLoved, + unfavorite: context.l10n.trackOptionRemoveFromLoved, + output: context.l10n.nowPlayingAudioOutput, + )), + ); ref.listen(settingsProvider.select((s) => s.playbackNormalization), ( _, enabled, diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index bcc47361..8cb5a027 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'dart:math'; import 'package:audio_service/audio_service.dart'; +import 'package:flutter/services.dart'; +import 'package:spotiflac_android/services/playback_notification.dart'; import 'package:spotiflac_android/services/player_widget_service.dart'; import 'package:audio_session/audio_session.dart' show AudioSession, AudioSessionConfiguration, AudioInterruptionType; @@ -38,6 +40,46 @@ void updateMusicPlayerStrings({ bool _playbackNormalizationEnabled = false; bool _autoMixEnabled = false; MusicPlayerHandler? _activeMusicPlayerHandler; +PlaybackNotification _notificationPresentation = const PlaybackNotification(); +Future Function(MediaItem)? _toggleNotificationFavorite; +const _notificationChannel = MethodChannel( + 'com.zarz.spotiflac/playback_notification', +); + +void configurePlaybackNotification({ + required PlaybackNotification presentation, + required Future Function(MediaItem) toggleFavorite, +}) { + _notificationPresentation = presentation; + _toggleNotificationFavorite = toggleFavorite; + _activeMusicPlayerHandler?._refreshNotificationControls(); + if (Platform.isIOS) { + _notificationChannel.setMethodCallHandler((call) async { + if (call.method == 'favorite') { + await _activeMusicPlayerHandler?.customAction( + PlaybackNotification.favoriteAction, + ); + } + }); + unawaited(_publishIosNotificationFavorite()); + } +} + +Future _publishIosNotificationFavorite() async { + try { + await _notificationChannel.invokeMethod('update', { + 'enabled': + _notificationPresentation.mornye && + _notificationPresentation.mediaId != null, + 'loved': _notificationPresentation.loved, + 'label': _notificationPresentation.favoriteLabel, + }); + } on MissingPluginException { + // Unit tests do not install the iOS remote-command bridge. + } on PlatformException catch (error) { + _log.w('Could not update the system favorite action: ${error.code}'); + } +} /// Enables/disables ReplayGain volume normalization and re-applies it to the /// track currently playing. @@ -559,11 +601,10 @@ class MusicPlayerHandler extends BaseAudioHandler playbackState.add( playbackState.value.copyWith( - controls: [ - MediaControl.skipToPrevious, - if (playing) MediaControl.pause else MediaControl.play, - MediaControl.skipToNext, - ], + controls: _notificationPresentation.controls( + playing: playing, + item: mediaItem.value, + ), systemActions: const { MediaAction.seek, MediaAction.seekForward, @@ -571,7 +612,9 @@ class MusicPlayerHandler extends BaseAudioHandler MediaAction.skipToPrevious, MediaAction.skipToNext, }, - androidCompactActionIndices: const [0, 1, 2], + androidCompactActionIndices: _notificationPresentation.mornye + ? const [1, 2, 3] + : const [0, 1, 2], processingState: (loading == true) ? AudioProcessingState.loading : _mapProcessingState(state), @@ -586,6 +629,48 @@ class MusicPlayerHandler extends BaseAudioHandler ); } + void _refreshNotificationControls() { + if (_disposed) return; + final state = playbackState.value; + playbackState.add( + state.copyWith( + // copyWith creates a fresh updateTime; keep the extrapolated position + // so changing a star or theme cannot pull the scrubber backwards. + updatePosition: state.position, + controls: _notificationPresentation.controls( + playing: state.playing, + item: mediaItem.value, + ), + androidCompactActionIndices: _notificationPresentation.mornye + ? const [1, 2, 3] + : const [0, 1, 2], + ), + ); + } + + bool _savingNotificationFavorite = false; + + @override + Future customAction( + String name, [ + Map? extras, + ]) async { + if (name == PlaybackNotification.favoriteAction) { + final item = mediaItem.value; + if (item == null || _savingNotificationFavorite) return; + _savingNotificationFavorite = true; + try { + await _toggleNotificationFavorite?.call(item); + } catch (error) { + _log.w('Notification favorite failed: $error'); + } finally { + _savingNotificationFavorite = false; + } + } else { + return super.customAction(name, extras); + } + } + void _broadcastPosition(Duration position, {bool force = false}) { final now = DateTime.now(); final lastAt = _lastPositionBroadcastAt; diff --git a/lib/services/playback_notification.dart b/lib/services/playback_notification.dart new file mode 100644 index 00000000..bca36752 --- /dev/null +++ b/lib/services/playback_notification.dart @@ -0,0 +1,63 @@ +import 'package:audio_service/audio_service.dart'; + +/// Presentation only; every transport action still uses the audio handler. +class PlaybackNotification { + static const favoriteAction = 'spotiflac.favorite'; + static const outputAction = 'spotiflac.audioOutput'; + + final bool mornye; + final String? mediaId; + final String? source; + final bool loved; + final String favoriteLabel; + final String outputLabel; + + const PlaybackNotification({ + this.mornye = false, + this.mediaId, + this.source, + this.loved = false, + this.favoriteLabel = 'Favorite', + this.outputLabel = 'Audio Output', + }); + + List controls({ + required bool playing, + required MediaItem? item, + }) { + if (!mornye) { + return [ + MediaControl.skipToPrevious, + playing ? MediaControl.pause : MediaControl.play, + MediaControl.skipToNext, + ]; + } + final current = item?.id == mediaId && item?.extras?['source'] == source; + return [ + MediaControl.custom( + name: favoriteAction, + androidIcon: current && loved + ? 'drawable/ic_notification_star_filled' + : 'drawable/ic_notification_star', + label: favoriteLabel, + ), + MediaControl.skipToPrevious.copyWith( + androidIcon: 'drawable/ic_widget_previous', + ), + (playing ? MediaControl.pause : MediaControl.play).copyWith( + androidIcon: playing + ? 'drawable/ic_widget_pause' + : 'drawable/ic_widget_play', + ), + MediaControl.skipToNext.copyWith(androidIcon: 'drawable/ic_widget_next'), + MediaControl.custom( + name: outputAction, + androidIcon: 'drawable/ic_notification_airplay', + label: outputLabel, + extras: const { + 'spotiflac.activity': 'com.zarz.spotiflac.AudioOutputActivity', + }, + ), + ]; + } +} diff --git a/lib/services/player_widget_service.dart b/lib/services/player_widget_service.dart index 6a83731d..56ec88b1 100644 --- a/lib/services/player_widget_service.dart +++ b/lib/services/player_widget_service.dart @@ -122,8 +122,12 @@ class PlayerWidgetService { 'album': item?.album ?? '', 'playing': item != null && state.playing, 'canPlay': item != null, - 'canPrevious': state.controls.contains(MediaControl.skipToPrevious), - 'canNext': state.controls.contains(MediaControl.skipToNext), + 'canPrevious': state.controls.any( + (control) => control.action == MediaAction.skipToPrevious, + ), + 'canNext': state.controls.any( + (control) => control.action == MediaAction.skipToNext, + ), 'artworkKey': _artwork == null ? '' : _artUri.toString(), 'background': _artwork?.background ?? 0xff292433, }; diff --git a/pubspec.lock b/pubspec.lock index 36fd63f1..036aa90e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -76,10 +76,9 @@ packages: audio_service: dependency: "direct main" description: - name: audio_service - sha256: "95f3267f3449eb5cf71c8fcf1d556f57af1e898e2dc5815fb168d1843653edb7" - url: "https://pub.dev" - source: hosted + path: "third_party/audio_service" + relative: true + source: path version: "0.18.19" audio_service_platform_interface: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index 81d48e7d..9287fc10 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -75,7 +75,8 @@ dependencies: # Notifications flutter_local_notifications: ^22.3.0 audioplayers: ^6.8.1 - audio_service: ^0.18.19 + audio_service: + path: third_party/audio_service audio_session: ^0.2.4 dev_dependencies: diff --git a/test/music_player_automix_test.dart b/test/music_player_automix_test.dart index 376cf0f4..d6bb9721 100644 --- a/test/music_player_automix_test.dart +++ b/test/music_player_automix_test.dart @@ -7,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/services/automix_analysis.dart'; import 'package:spotiflac_android/services/automix_analyzer.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; +import 'package:spotiflac_android/services/playback_notification.dart'; class _Analyzer extends AutoMixAnalyzer { final calls = []; @@ -141,6 +142,10 @@ void main() { tearDown(() async { await handler.dispose(); + configurePlaybackNotification( + presentation: const PlaybackNotification(), + toggleFavorite: (_) async {}, + ); analyzer.pending?.complete(null); setAutoMixEnabled(false); await Future.delayed(const Duration(milliseconds: 30)); @@ -148,6 +153,89 @@ void main() { expect(native.playing, isEmpty); }); + test('Mornye notification follows theme and keeps transport state', () async { + await handler.restoreSession( + items: _tracks, + index: 0, + position: const Duration(seconds: 12), + shuffle: false, + ); + configurePlaybackNotification( + presentation: const PlaybackNotification( + mornye: true, + mediaId: 'one', + source: '/one.flac', + loved: true, + ), + toggleFavorite: (_) async {}, + ); + final state = handler.playbackState.value; + expect(state.playing, isFalse); + expect(state.updatePosition, const Duration(seconds: 12)); + expect(state.controls.map((control) => control.action), [ + MediaAction.custom, + MediaAction.skipToPrevious, + MediaAction.play, + MediaAction.skipToNext, + MediaAction.custom, + ]); + expect(state.controls.first.androidIcon, contains('star_filled')); + expect(state.androidCompactActionIndices, [1, 2, 3]); + + configurePlaybackNotification( + presentation: const PlaybackNotification(), + toggleFavorite: (_) async {}, + ); + expect(handler.playbackState.value.controls, [ + MediaControl.skipToPrevious, + MediaControl.play, + MediaControl.skipToNext, + ]); + expect(handler.playbackState.value.androidCompactActionIndices, [0, 1, 2]); + expect(handler.playbackState.value.updatePosition, state.updatePosition); + handler.playbackState.add( + PlaybackState( + playing: true, + processingState: AudioProcessingState.ready, + updatePosition: const Duration(seconds: 12), + updateTime: DateTime.now().subtract(const Duration(seconds: 5)), + ), + ); + configurePlaybackNotification( + presentation: const PlaybackNotification(mornye: true), + toggleFavorite: (_) async {}, + ); + expect( + handler.playbackState.value.updatePosition.inMilliseconds, + inInclusiveRange(17000, 17200), + reason: 'Changing icons must not reset the live playback position', + ); + }); + + test( + 'notification favorite retains clicked track and ignores double taps', + () async { + final save = Completer(); + final selected = []; + configurePlaybackNotification( + presentation: const PlaybackNotification(mornye: true), + toggleFavorite: (item) async { + selected.add(item.id); + await save.future; + }, + ); + handler.mediaItem.add(_tracks.first.toMediaItem()); + final first = handler.customAction(PlaybackNotification.favoriteAction); + handler.mediaItem.add(_tracks.last.toMediaItem()); + await handler.customAction(PlaybackNotification.favoriteAction); + expect(selected, ['one']); + save.complete(); + await first; + await handler.customAction(PlaybackNotification.favoriteAction); + expect(selected, ['one', 'three']); + }, + ); + Future prepare() async { setAutoMixEnabled(true); await handler.setQueueAndPlay(_tracks); diff --git a/test/player_widget_service_test.dart b/test/player_widget_service_test.dart index d2f6949c..25707313 100644 --- a/test/player_widget_service_test.dart +++ b/test/player_widget_service_test.dart @@ -4,6 +4,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/services/player_widget_service.dart'; +import 'package:spotiflac_android/services/playback_notification.dart'; class _Player extends BaseAudioHandler { final actions = []; @@ -156,6 +157,21 @@ void main() { }, ); + test('Mornye notification icons do not disable widget transport', () async { + final media = item('one'); + player.mediaItem.add(media); + player.playbackState.add( + PlaybackState( + controls: const PlaybackNotification( + mornye: true, + ).controls(playing: true, item: media), + ), + ); + await settle(); + expect(updates.last['canPrevious'], isTrue); + expect(updates.last['canNext'], isTrue); + }); + test( 'clearing playback also removes artwork and disables the player', () async { diff --git a/third_party/audio_service/CHANGELOG.md b/third_party/audio_service/CHANGELOG.md new file mode 100644 index 00000000..0efbe7fc --- /dev/null +++ b/third_party/audio_service/CHANGELOG.md @@ -0,0 +1,447 @@ +## 0.18.19 + +* Support AGP 9. +* Migrate Android build files to .kts + +## 0.18.18 + +* Fix setPlaybackState entitlement issue on iOS. +* Fix Android stopForeground deprecation warning. +* Bump compile/target SDK to 35 on Android. +* Bump minSdk to 19 on Android. +* bump AGP to 8.5.2. + +## 0.18.17 + +* Add support for SwiftPM. + +## 0.18.16 + +* Support MPNowPlayingInfoPropertyIsLiveStream on IOS (@MuradSh, @celsoft). + +## 0.18.15 + +* Add deep link support for FlutterFragmentActivity (@jan-milovanovic). +* Fix JDK 21 compile error. + +## 0.18.14 + +* Support rxdart 0.28.x. + +## 0.18.13 + +* Fix Android NPE in sendNotificationClicked and onConnected. +* Catch and broadcast async platform exceptions. +* Support js '>=0.6.3 <0.8.0' +* Fix setAndroidPlaybackInfo call blocking (@julianscheel). +* Pass through missing extras to playFrom.../prepareFrom... (@Ruchit2759). + +## 0.18.12 + +* Fix crash with Oppo/OnePlus devices running Android 13. + +## 0.18.11 + +* Support custom media controls (@defsub) +* Support fast forward, rewind and stop when targeting Android 13 (@defsub) + +## 0.18.10 + +* Add support for AGP 8 (@theskyblockman). +* Update AGP to 7.3.0. +* Update minimum flutter version to 3.0. + +## 0.18.9 + +* Fix cache bug in AudioServiceFragmentActivity (@Mordtimer). +* Add Android Auto manifest entry for example app (@ColinSchmale). + +## 0.18.8 + +* Improve efficiency of mediaItem updates (@nt4f04uNd). + +## 0.18.7 + +* Fix stopForeground bug on Android SDK < 24. +* Migrate to androidx.media 1.6.0 (@snipd-mikel) +* Propagate MediaItem extras to Android Auto (@snipd-mikel) +* Update progress bar in Android Auto (@snipd-mikel) + +## 0.18.6 + +* Fix build when targeting Android 13. +* Add MediaItem.artHeaders. + +## 0.18.5 + +* Add AudioServiceFragmentActivity (@deimantasa). +* Support `content://` art URIs in notification on Android (@nt4f04uNd). +* Document Android foregroundServiceType. + +## 0.18.4 + +* Fix Android FlutterJNI error after quick relaunch. +* Fix Android NPE when destroying additional FlutterEngines. + +## 0.18.3 + +* Fix build when targeting Android 12. + +## 0.18.2 + +* Guard against NPE when Android service is destroyed quickly. +* Migrate to flutter_lints. +* Queue messages from platform if init() called late. +* Fix deep linking on Android (@vishna/@ryanheise). + +## 0.18.1 + +* Remove iOS notification on stop. +* Fix setSpeed action on iOS. +* Eliminate redundant notification updates on Android. +* Handle null album and artist on web (@nt4f04uNd). +* Fix multithreaded crash in notification tap (@nt4f04uNd). +* Fix regression to show album art on lock screen (@nt4f04uNd). +* Add playlist/shuffle/loop example. + +## 0.18.0 + +* Use a single isolate for easier communication. +* Replace BackgroundAudioTask by AudioHandler. +* Replace AudioService.start by AudioService.init. +* Android Auto support. +* Android 11 media session resumption support. +* Federated plugin model. +* Composable audio handlers (@yringler). +* More callbacks: + * prepareFromSearch + * prepareFromUri + * playFromSearch + * playFromUri + * addQueueItems + * removeQueueItemAt + * setCaptioningEnabled + * getMediaItem + * search + * androidSetRemoteVolume + * androidAdjustRemoteVolume +* More state: + * queueTitle + * ratingStyle + * androidPlaybackInfo + * customState +* Default platform implementation for Windows/Linux (@keaganhilliard) +* iOS/macOS control center bug fixes (@nt4f04uNd) +* Fix queue index out of bounds bug (@kcrebound) +* Fix bug when starting foreground service from background (@chengyuhui) +* Make MediaItem.album nullable (@letiagoalves) +* Code quality: + * Unit tests (@suragch, @nt4f04uNd) + * Strong-mode and pedantic lints, code consistency (@nt4f04uNd) +* Improve artUri performance on Android (@nt4f04uNd) +* Better detection of browser support (@nt4f04uNd) + +## 0.17.1 + +* Support rxdart 0.27.0. + +## 0.17.0 + +* Null safety. +* Change artUri type from String to Uri. + +## 0.16.2+1 + +* Mention upcoming 0.18.0 release in README. + +## 0.16.2 + +* Fix positionStream bug when seek is interrupted by onStop. +* Fix JS name clash for MediaMetadata. +* Update NowPlayingInfo speed correctly on iOS (@ryotayama). + +## 0.16.1 + +* Fix bug in start() when using HttpOverrides. + +## 0.16.0 + +* setState parameters default to previous state. +* Change updateTime from Duration to DateTime. +* Rename newStartRating to newStarRating. +* Declare type of MediaItem.extras (@hacker1024). +* Unit tests. +* Fix compile error on macOS. +* Update dependencies. + +## 0.15.3 + +* Add positionStream and runningStream. +* Add androidShowNotificationBadge option (@aleexbt). + +## 0.15.2 + +* Process connect/disconnect/start requests in a queue. +* Guard against null setState arguments. +* Range check in onSkipToPrevious (@snaeji). + +## 0.15.1 + +* Fix loading of file:// artUri values. +* Allow booleans/doubles in MediaItems. +* Silently ignore duplicate onStop requests. + +## 0.15.0 + +* Web support (@keaganhilliard) +* macOS support (@hacker1024) +* Route next/previous buttons to onClick on Android (@stonega) +* Correctly scale skip intervals for control center (@subhash279) +* Handle repeated stop/start calls more robustly. +* Fix Android 11 bugs. + +## 0.14.1 + +* audio_session dependency now supports minSdkVersion 16 on Android. + +## 0.14.0 + +* audio session management now handled by audio_session (see [Migration Guide](https://github.com/ryanheise/audio_service/wiki/Migration-Guide#0140)). +* Exceptions in background audio task are logged and forwarded to client. + +## 0.13.0 + +* All BackgroundAudioTask callbacks are now async. +* Add default implementation of onSkipToNext/onSkipToPrevious. +* Bug fixes. + +## 0.12.0 + +* Add setRepeatMode/setShuffleMode. +* Enable iOS Control Center buttons based on setState. +* Support seek forward/backward in iOS Control Center. +* Add default behaviour to BackgroundAudioTask. +* Bug fixes. +* Simplify example. + +## 0.11.2 + +* Fix bug with album metadata on Android. + +## 0.11.1 + +* Allow setting the iOS audio session category and options. +* Allow AudioServiceWidget to recognise swipe gesture on iOS. +* Check for null title and album on Android. + +## 0.11.0 + +* Breaking change: onStop must await super.onStop to shutdown task. +* Fix Android memory leak. + +## 0.10.0 + +* Replace androidStopOnRemoveTask with onTaskRemoved callback. +* Add onClose callback. +* Breaking change: new MediaButtonReceiver in AndroidManifest.xml. + +## 0.9.0 + +* New state model: split into playing + processingState. +* androidStopForegroundOnPause ties foreground state to playing state. +* Add MediaItem.toJson/fromJson. +* Add AudioService.notificationClickEventStream (Android). +* Add AudioService.updateMediaItem. +* Add AudioService.setSpeed. +* Add PlaybackState.bufferedPosition. +* Add custom AudioService.start parameters. +* Rename replaceQueue -> updateQueue. +* Rename Android-specific start parameters with android- prefix. +* Use Duration type for all time values. +* Pass fastForward/rewind intervals through to background task. +* Allow connections from background contexts (e.g. android_alarm_manager). +* Unify iOS/Android focus APIs. +* Bug fixes and dependency updates. + +## 0.8.0 + +* Allow UI to await the result of custom actions. +* Allow background to broadcast custom events to UI. +* Improve memory management for art bitmaps on Android. +* Convenience methods: replaceQueue, playMediaItem, addQueueItems. +* Bug fixes and dependency updates. + +## 0.7.2 + +* Shutdown background task if task killed by IO (Android). +* Bug fixes and dependency updates. + +## 0.7.1 + +* Add AudioServiceWidget to auto-manage connections. +* Allow file URIs for artUri. + +## 0.7.0 + +* Support skip forward/backward in command center (iOS). +* Add 'extras' field to MediaItem. +* Artwork caching and preloading supported on Android+iOS. +* Bug fixes. + +## 0.6.2 + +* Bug fixes. + +## 0.6.1 + +* Option to stop service on closing task (Android). + +## 0.6.0 + +* Migrated to V2 embedding API (Flutter 1.12). + +## 0.5.7 + +* Destroy isolates after use. + +## 0.5.6 + +* Support Flutter 1.12. + +## 0.5.5 + +* Bump sdk version to 2.6.0. + +## 0.5.4 + +* Fix Android memory leak. + +## 0.5.3 + +* Support Queue, album art and other missing features on iOS. + +## 0.5.2 + +* Update documentation and example. + +## 0.5.1 + +* Playback state broadcast on connect (iOS). + +## 0.5.0 + +* Partial iOS support. + +## 0.4.2 + +* Option to call stopForeground on pause. + +## 0.4.1 + +* Fix queue support bug + +## 0.4.0 + +* Breaking change: AudioServiceBackground.run takes a single parameter. + +## 0.3.1 + +* Update example to disconnect when pressing back button. + +## 0.3.0 + +* Breaking change: updateTime now measured since epoch instead of boot time. + +## 0.2.1 + +* Streams use RxDart BehaviorSubject. + +## 0.2.0 + +* Migrate to AndroidX. + +## 0.1.1 + +* Bump targetSdkVersion to 28 +* Clear client-side metadata and state on stop. + +## 0.1.0 + +* onClick is now always called for media button clicks. +* Option to set notifications as ongoing. + +## 0.0.15 + +* Option to set subText in notification. +* Support media item ratings + +## 0.0.14 + +* Can update existing media items. +* Can specify order of Android notification compact actions. +* Bug fix with connect. + +## 0.0.13 + +* Option to preload artwork. +* Allow client to browse media items. + +## 0.0.12 + +* More options to customise the notification content. + +## 0.0.11 + +* Breaking API changes. +* Connection callbacks replaced by a streams API. +* AudioService properties for playbackState, currentMediaItem, queue. +* Option to set Android notification channel description. +* AudioService.customAction awaits completion of the action. + +## 0.0.10 + +* Bug fixes with queue management. +* AudioService.start completes when the background task is ready. + +## 0.0.9 + +* Support queue management. + +## 0.0.8 + +* Bug fix. + +## 0.0.7 + +* onMediaChanged takes MediaItem parameter. +* Support playFromMediaId, fastForward, rewind. + +## 0.0.6 + +* All APIs address media items by String mediaId. + +## 0.0.5 + +* Show media art in notification and lock screen. + +## 0.0.4 + +* Support and example for playing TextToSpeech. +* Click notification to launch UI. +* More properties added to MediaItem. +* Minor API changes. + +## 0.0.3 + +* Pause now keeps background isolate running +* Notification channel id is generated from package name +* Updated example to use audioplayer plugin +* Fixed media button handling + +## 0.0.2 + +* Better connection handling. + +## 0.0.1 + +* Initial release. diff --git a/third_party/audio_service/LICENSE b/third_party/audio_service/LICENSE new file mode 100644 index 00000000..3bab693e --- /dev/null +++ b/third_party/audio_service/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018-2021 Ryan Heise and the project contributors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/audio_service/README.md b/third_party/audio_service/README.md new file mode 100644 index 00000000..8ca0cc1c --- /dev/null +++ b/third_party/audio_service/README.md @@ -0,0 +1,425 @@ +# audio_service + +This plugin wraps around your existing audio code to allow it to run in the background and interact with the media notification, the lock screen, headset buttons, wearables and Android Auto. It supports Android, iOS, web and Linux (via [audio_service_mpris](https://pub.dev/packages/audio_service_mpris)). It is suitable for: + +* Music players +* Text-to-speech readers +* Podcast players +* Video players +* Navigators +* More! + +## How does this plugin work? + +You encapsulate your audio code in an audio handler which implements a set of standard system callbacks to handle media playback requests from different sources in a uniform way: + +![audio_handler](https://user-images.githubusercontent.com/19899190/100403242-762e7480-30b2-11eb-9fcf-938e08beee53.png) + +You implement these callbacks to play the particular type of audio that your app needs to play in response to these requests. For example, a Text-to-speech reader app might implement these callbacks using [flutter_tts](https://pub.dartlang.org/packages/flutter_tts) to render the speech, while a music player app might implement these callbacks using [just_audio](https://pub.dartlang.org/packages/just_audio) to render the audio. + +| Feature | Android | iOS | macOS | Web | +| ------- | :-------: | :-----: | :-----: | :-----: | +| background audio | ✅ | ✅ | ✅ | ✅ | +| headset clicks | ✅ | ✅ | ✅ | ✅ | +| play/pause/seek/rate/stop | ✅ | ✅ | ✅ | ✅ | +| fast forward/rewind | ✅ | ✅ | ✅ | ✅ | +| repeat/shuffle mode | ✅ | ✅ | ✅ | ✅ | +| queue manipulation, skip next/prev | ✅ | ✅ | ✅ | ✅ | +| custom actions/events/states | ✅ | ✅ | ✅ | ✅ | +| notifications/control center | ✅ | ✅ | ✅ | ✅ | +| lock screen controls | ✅ | ✅ | | ✅ | +| album art | ✅ | ✅ | ✅ | ✅ | +| Android Auto, Apple CarPlay | ✅ | ✅ | | | + +If you'd like to help with any missing features, please join us on the [GitHub issues page](https://github.com/ryanheise/audio_service/issues). + +## Tutorials and documentation + +* [Background audio in Flutter with Audio Service and Just Audio](https://suragch.medium.com/background-audio-in-flutter-with-audio-service-and-just-audio-3cce17b4a7d?sk=0837a1b1773e27a4f879ff3072e90305) by @suragch +* [Tutorial](https://github.com/ryanheise/audio_service/wiki/Tutorial): walks you through building a simple audio player while explaining the basic concepts. +* [Full example](https://github.com/ryanheise/audio_service): The `example` subdirectory on GitHub demonstrates both music and text-to-speech use cases. +* [Frequently Asked Questions](https://github.com/ryanheise/audio_service/wiki/FAQ) +* [API documentation](https://pub.dev/documentation/audio_service/latest/audio_service/audio_service-library.html) + +## Can I make use of other plugins within the audio handler? + +Yes! `audio_service` is designed to let you implement the audio logic however you want, using whatever plugins you want. You can use your favourite audio plugins such as [just_audio](https://pub.dartlang.org/packages/just_audio), [flutter_tts](https://pub.dartlang.org/packages/flutter_tts), and others, within your audio handler. There are also plugins like [just_audio_handlers](https://github.com/yringler/inside-app/tree/master/just_audio_handlers) that provide default implementations of `AudioHandler` to make your job easier. + +Note that this plugin will not work with other audio plugins that overlap in responsibility with this plugin (i.e. background audio, iOS control center, Android notifications, lock screen, headset buttons, etc.) + +## Example + +### Initialisation + +Define your `AudioHandler` with the callbacks that you want your app to handle: + +```dart +class MyAudioHandler extends BaseAudioHandler + with QueueHandler, // mix in default queue callback implementations + SeekHandler { // mix in default seek callback implementations + + final _player = AudioPlayer(); // e.g. just_audio + + // The most common callbacks: + Future play() => _player.play(); + Future pause() => _player.pause(); + Future stop() => _player.stop(); + Future seek(Duration position) => _player.seek(position); + Future skipToQueueItem(int i) => _player.seek(Duration.zero, index: i); +} +``` + +Register your `AudioHandler` during app startup: + +```dart +Future main() async { + // store this in a singleton + _audioHandler = await AudioService.init( + builder: () => MyAudioHandler(), + config: AudioServiceConfig( + androidNotificationChannelId: 'com.mycompany.myapp.channel.audio', + androidNotificationChannelName: 'Music playback', + ), + ); + runApp(new MyApp()); +} +``` + +### Sending requests to the audio handler from Flutter + +Standard controls: + +```dart +_audioHandler.play(); +_audioHandler.seek(Duration(seconds: 10)); +_audioHandler.setSpeed(1.5); +_audioHandler.pause(); +_audioHandler.stop(); +``` + +Playing specific media items: + +```dart +var item = MediaItem( + id: 'https://example.com/audio.mp3', + album: 'Album name', + title: 'Track title', + artist: 'Artist name', + duration: const Duration(milliseconds: 123456), + artUri: Uri.parse('https://example.com/album.jpg'), +); + +_audioHandler.playMediaItem(item); +_audioHandler.playFromSearch(queryString); +_audioHandler.playFromUri(uri); +_audioHandler.playFromMediaId(id); +``` + +Queue management: + +```dart +_audioHandler.addQueueItem(item); +_audioHandler.insertQueueItem(1, item); +_audioHandler.removeQueueItem(item); +_audioHandler.updateQueue([item, ...]); +_audioHandler.skipToNext(); +_audioHandler.skipToPrevious(); +_audioHandler.skipToQueueItem(2); +``` + +Looping and shuffling: + +```dart +_audioHandler.setRepeatMode(AudioServiceRepeatMode.one); // none/one/all/group +_audioHandler.setShuffleMode(AudioServiceShuffleMode.all); // none/all/group +``` + +Custom actions: + +```dart +_audioHandler.customAction('setVolume', {'volume': 0.8}); +_audioHandler.customAction('saveBookmark'); +``` + +### Broadcasting state changes + +Your audio handler must broadcast state changes so that the system notification and smart watches (etc) know what state to display. Your app's Flutter UI may also listen to these state changes so that it knows what state to display. Thus, the audio handler provides a single source of truth for your audio state to all clients. + +Broadcast the current media item: + +```dart +class MyAudioHandler extends BaseAudioHandler ... { + ... + mediaItem.add(item1); + ... +``` + +Broadcast the current queue: + +```dart + ... + queue.add([item1, item2, item3]); + ... +``` + +Broadcast the current playback state: + +```dart + ... + // All options shown: + playbackState.add(PlaybackState( + // Which buttons should appear in the notification now + controls: [ + MediaControl.skipToPrevious, + MediaControl.pause, + MediaControl.stop, + MediaControl.skipToNext, + ], + // Which other actions should be enabled in the notification + systemActions: const { + MediaAction.seek, + MediaAction.seekForward, + MediaAction.seekBackward, + }, + // Which controls to show in Android's compact view. + androidCompactActionIndices: const [0, 1, 3], + // Whether audio is ready, buffering, ... + processingState: AudioProcessingState.ready, + // Whether audio is playing + playing: true, + // The current position as of this update. You should not broadcast + // position changes continuously because listeners will be able to + // project the current position after any elapsed time based on the + // current speed and whether audio is playing and ready. Instead, only + // broadcast position updates when they are different from expected (e.g. + // buffering, or seeking). + updatePosition: Duration(milliseconds: 54321), + // The current buffered position as of this update + bufferedPosition: Duration(milliseconds: 65432), + // The current speed + speed: 1.0, + // The current queue position + queueIndex: 0, + )); +``` + +Broadcasting mutations of the current playback state using `copyWith`: + +```dart + playbackState.add(playbackState.value.copyWith( + // Keep all existing state the same with only the speed changed: + speed: newSpeed, + )); +``` + +### Listening to state changes + +Listen to changes to the currently playing item from the Flutter UI: + +```dart +_audioHandler.mediaItem.listen((MediaItem item) { ... }); +``` + +Listen to changes to the queue from the Flutter UI: + +```dart +_audioHandler.queue.listen((List queue) { ... }); +``` + +Listen to playback state changes from the Flutter UI: + +```dart +_audioHandler.playbackState.listen((PlaybackState state) { + if (state.playing) ... else ... + switch (state.processingState) { + case AudioProcessingState.idle: ... + case AudioProcessingState.loading: ... + case AudioProcessingState.buffering: ... + case AudioProcessingState.ready: ... + case AudioProcessingState.completed: ... + case AudioProcessingState.error: ... + } +}); +``` + +Listen to a stream of continuous changes to the current playback position: + +```dart +AudioService.position.listen((Duration position) { ... }); +``` + +### Advanced features + +Compose multiple audio handler classes: + +```dart +_audioHandler = await AudioService.init( + builder: () => AnalyticsAudioHandler( + PersistingAudioHandler( + MyAudioHandler())), +); +``` + +Connecting from another isolate: + +```dart +// Wrap audio handler in IsolatedAudioHandler: +_audioHandler = await AudioService.init( + builder: () => IsolatedAudioHandler( + MyAudioHandler(), + portName: 'my_audio_handler', + ), +); +// From another isolate, obtain a proxy reference: +_proxyAudioHandler = await IsolatedAudioHandler.lookup( + portName: 'my_audio_handler', +); +``` + +See the full example for how to handle queues/playlists, headset button clicks, media artwork and text to speech. + +## Configuring the audio session + +If your app uses audio, you should tell the operating system what kind of usage scenario your app has and how your app will interact with other audio apps on the device. Different audio apps often have unique requirements. For example, when a navigator app speaks driving instructions, a music player should duck its audio while a podcast player should pause its audio. Depending on which one of these three apps you are building, you will need to configure your app's audio settings and callbacks to appropriately handle these interactions. + +Use the [audio_session](https://pub.dev/packages/audio_session) package to change the default audio session configuration for your app. E.g. for a podcast player, you may use: + +```dart +final session = await AudioSession.instance; +await session.configure(AudioSessionConfiguration.speech()); +``` + +Each time you invoke an audio plugin to play audio, that plugin will activate your app's shared audio session to inform the operating system that your app is actively playing audio. Depending on the configuration set above, this will also inform other audio apps to either stop playing audio, or possibly continue playing at a lower volume (i.e. ducking). You normally do not need to activate the audio session yourself, however if the audio plugin you use does not activate the audio session, you can activate it yourself: + +```dart +// Activate the audio session before playing audio. +if (await session.setActive(true)) { + // Now play audio. +} else { + // The request was denied and the app should not play audio +} +``` + +When another app activates its audio session, it similarly may ask your app to pause or duck its audio. Once again, the particular audio plugin you use may automatically pause or duck audio when requested. However, if it does not, you can respond to these events yourself by listening to `session.interruptionEventStream`. Similarly, if the audio plugin doesn't handle unplugged headphone events, you can respond to these yourself by listening to `session.becomingNoisyEventStream`. For more information, consult the documentation for [audio_session](https://pub.dev/packages/audio_session). + +Note: If your app uses a number of different audio plugins, e.g. for audio recording, or text to speech, or background audio, it is possible that those plugins may internally override each other's audio session settings since there is only a single audio session shared by your app. Therefore, it is recommended that you apply your own preferred configuration using audio_session after all other audio plugins have loaded. You may consider asking the developer of each audio plugin you use to provide an option to not overwrite these global settings and allow them be managed externally. + +## Android setup + +1. Make the following changes to your project's `AndroidManifest.xml` file: + +```xml + + + + + + + + + + ... + + + + ... + + + + + + + + + + + + + + + + + +``` + +Note: As of Android 12, an app must have permission to restart a foreground service in the background, otherwise a `ForegroundServiceStartNotAllowedException` will be thrown. To avoid such an exception, you can either set `androidStopForegroundOnPause` to `false` in your `AudioServiceConfig` which keeps the service in the foreground during a pause so that restarting the foreground service is unnecessary, or you can keep the default `androidStopForegroundOnPause` setting of `true` (in line with best practices) and request the user to turn of battery optimisation for your app via the [optimize_battery](https://pub.dev/packages/optimize_battery) package. For more information, read [this page](https://developer.android.com/training/monitoring-device-state/doze-standby#support_for_other_use_cases). + +2. If you use any custom icons in notification, create the file `android/app/src/main/res/raw/keep.xml` to prevent them from being stripped during the build process: + +```xml + + +``` + +By default plugin's default icons are not stripped by R8. If you don't use them, you may selectively strip them. For example, the rules below will keep all your icons and discard all the plugin's: + +```xml + + +``` + +For more information about shrinking see [Android documentation](https://developer.android.com/studio/build/shrink-code#keep-resources). + + +### Custom Android activity + +If your app needs to use its own custom activity, make sure you update your `AndroidManifest.xml` file to reference your activity's class name instead of `AudioServiceActivity`. For example, if your activity class is named `MainActivity`, then use: + +```xml + +``` + +Depending on whether you activity is a regular `Activity` or a `FragmentActivity`, you must also include some code to link to audio_service's shared `FlutterEngine`. The easiest way to accomplish this is to inherit that code from one of audio_service's provided base classes. + +1. Integration as an `Activity`: + +```java +import com.ryanheise.audioservice.AudioServiceActivity; + +class MainActivity extends AudioServiceActivity { + // ... +} +``` + +2. Integration as a `FragmentActivity`: + +```java +import com.ryanheise.audioservice.AudioServiceFragmentActivity; + +class MainActivity extends AudioServiceFragmentActivity { + // ... +} +``` + +You can also write your own activity class from scratch, and override the `provideFlutterEngine`, `getCachedEngineId` and `shouldDestroyEngineWithHost` methods yourself. For inspiration, see the source code of the provided `AudioServiceActivity` and `AudioServiceFragmentActivity` classes. + +## iOS setup + +Insert this in your `Info.plist` file: + +``` + UIBackgroundModes + + audio + +``` + +The example project may be consulted for context. + +Note that the `audio` background mode permits an app to run in the background only for the purpose of playing audio. The OS may kill your process if it sits idly without playing audio, for example, by using a timer to sleep for a few seconds. If your app needs to pause for a few seconds between audio tracks, consider playing a silent audio track to create that effect rather than using an idle timer. + +## macOS setup + +The minimum supported macOS version is 10.12.2 (though this could be changed with some work in the future). +Modify the platform line in `macos/Podfile` to look like the following: + +``` +platform :osx, '10.12.2' +``` diff --git a/third_party/audio_service/SPOTIFLAC_PATCHES.md b/third_party/audio_service/SPOTIFLAC_PATCHES.md new file mode 100644 index 00000000..a03c4c09 --- /dev/null +++ b/third_party/audio_service/SPOTIFLAC_PATCHES.md @@ -0,0 +1,21 @@ +# Local audio_service 0.18.19 patch + +Source: the published `audio_service` 0.18.19 package (MIT; see LICENSE). +Only runtime sources are vendored. This replaces the same dependency, without +adding another playback engine or native SDK. + +- Android: include custom media actions in MediaStyle notifications on Android + 12 and older. Upstream exposes them only through PlaybackState, which hides + them on those versions. Keep custom action order and compact indices aligned. +- Android: allow a custom action to supply an explicit in-app activity class + using the `spotiflac.activity` extra. Its notification PendingIntent opens the + output picker directly. These activity controls are excluded from + PlaybackState custom actions: background launches from media-session binder + callbacks are blocked on newer Android. Use its native output switcher. + This is only for app-owned controls, not extension input. + +Android 13+ still chooses the positions and built-in transport icons in System +UI. We preserve real play/pause/previous/next semantics for headsets, lock screen +and Android Auto instead of disguising them as unrelated custom actions. + +When upgrading, compare these changes against the matching upstream files. diff --git a/third_party/audio_service/android/build.gradle.kts b/third_party/audio_service/android/build.gradle.kts new file mode 100644 index 00000000..0e1f9e5a --- /dev/null +++ b/third_party/audio_service/android/build.gradle.kts @@ -0,0 +1,73 @@ +group = "com.ryanheise.audioservice" +version = "1.0-SNAPSHOT" +val args = listOf("-Xlint:deprecation", "-Xlint:unchecked") + +buildscript { + // Uncomment when moving to Kotlin + // val kotlinVersion = "2.3.20" + val agpVersion = "9.0.1" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:$agpVersion") + // Uncomment when moving to Kotlin + // classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") +} + +tasks.withType().configureEach { + options.compilerArgs.addAll(args) +} + +// Uncomment when moving to Kotlin +// val agpMajor = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.substringBefore('.').toInt() +// if (agpMajor < 9) { +// apply(plugin = "org.jetbrains.kotlin.android") +// } + +android { + namespace = "com.ryanheise.audioservice" + compileSdk = 35 + + defaultConfig { + minSdk = 19 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + // Uncomment when moving to Kotlin + // sourceSets { + // getByName("main") { + // java.srcDirs("src/main/kotlin") + // } + // getByName("test") { + // java.srcDirs("src/test/kotlin") + // } + // } + + lint { + disable += listOf("InvalidPackage") + } +} + +dependencies { + implementation("androidx.media:media:1.7.0") + implementation("androidx.core:core:1.13.1") +} diff --git a/third_party/audio_service/android/gradle.properties b/third_party/audio_service/android/gradle.properties new file mode 100644 index 00000000..38c8d454 --- /dev/null +++ b/third_party/audio_service/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx1536M +android.enableR8=true +android.useAndroidX=true +android.enableJetifier=true diff --git a/third_party/audio_service/android/gradle/wrapper/gradle-wrapper.properties b/third_party/audio_service/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..48c0a02c --- /dev/null +++ b/third_party/audio_service/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/third_party/audio_service/android/settings.gradle.kts b/third_party/audio_service/android/settings.gradle.kts new file mode 100644 index 00000000..b731caaa --- /dev/null +++ b/third_party/audio_service/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "audio_service" diff --git a/third_party/audio_service/android/src/main/AndroidManifest.xml b/third_party/audio_service/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a2f47b60 --- /dev/null +++ b/third_party/audio_service/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioProcessingState.java b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioProcessingState.java new file mode 100644 index 00000000..d083855c --- /dev/null +++ b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioProcessingState.java @@ -0,0 +1,10 @@ +package com.ryanheise.audioservice; + +public enum AudioProcessingState { + idle, + loading, + buffering, + ready, + completed, + error, +} diff --git a/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioService.java b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioService.java new file mode 100644 index 00000000..e9b1670a --- /dev/null +++ b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioService.java @@ -0,0 +1,1193 @@ +package com.ryanheise.audioservice; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.ParcelFileDescriptor; +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.LruCache; +import android.util.Size; +import android.view.KeyEvent; + +import androidx.annotation.RequiresApi; +import androidx.core.app.ServiceCompat; +import androidx.core.content.ContextCompat; +import androidx.core.app.NotificationCompat; +import androidx.media.MediaBrowserServiceCompat; +import androidx.media.VolumeProviderCompat; +import androidx.media.app.NotificationCompat.MediaStyle; +import androidx.media.utils.MediaConstants; + +import java.io.FileDescriptor; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.flutter.embedding.engine.FlutterEngine; + +public class AudioService extends MediaBrowserServiceCompat { + public static final String CONTENT_STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED"; + public static final String CONTENT_STYLE_PLAYABLE_HINT = "android.media.browse.CONTENT_STYLE_PLAYABLE_HINT"; + public static final String CONTENT_STYLE_BROWSABLE_HINT = "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT"; + public static final int CONTENT_STYLE_LIST_ITEM_HINT_VALUE = 1; + public static final int CONTENT_STYLE_GRID_ITEM_HINT_VALUE = 2; + public static final int CONTENT_STYLE_CATEGORY_LIST_ITEM_HINT_VALUE = 3; + public static final int CONTENT_STYLE_CATEGORY_GRID_ITEM_HINT_VALUE = 4; + + private static final String SHARED_PREFERENCES_NAME = "audio_service_preferences"; + + private static final int NOTIFICATION_ID = 1124; + private static final int REQUEST_CONTENT_INTENT = 1000; + public static final String NOTIFICATION_CLICK_ACTION = "com.ryanheise.audioservice.NOTIFICATION_CLICK"; + public static final String CUSTOM_ACTION_STOP = "com.ryanheise.audioservice.action.STOP"; + public static final String CUSTOM_ACTION_FAST_FORWARD = "com.ryanheise.audioservice.action.FAST_FORWARD"; + public static final String CUSTOM_ACTION_REWIND = "com.ryanheise.audioservice.action.REWIND"; + private static final String BROWSABLE_ROOT_ID = "root"; + private static final String RECENT_ROOT_ID = "recent"; + // See the comment in onMediaButtonEvent to understand how the BYPASS keycodes work. + // 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 final long AUTO_ENABLED_ACTIONS = PlaybackStateCompat.ACTION_STOP + | PlaybackStateCompat.ACTION_PAUSE + | PlaybackStateCompat.ACTION_PLAY + | PlaybackStateCompat.ACTION_REWIND + // Auto-enabling these is bad for Android Auto since it forces the + // previous/next buttons to always show. + //| PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + //| PlaybackStateCompat.ACTION_SKIP_TO_NEXT + | PlaybackStateCompat.ACTION_FAST_FORWARD + | PlaybackStateCompat.ACTION_SET_RATING + // "seek" is the exception because it's the only action that + // affects the appearance of the media notification, so we leave it + // up to the plugin user whether to enable it (via systemActions). + //| PlaybackStateCompat.ACTION_SEEK_TO + | PlaybackStateCompat.ACTION_PLAY_PAUSE + | PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID + | PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH + | PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM + | PlaybackStateCompat.ACTION_PLAY_FROM_URI + | PlaybackStateCompat.ACTION_PREPARE + | PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID + | PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH + | PlaybackStateCompat.ACTION_PREPARE_FROM_URI + | PlaybackStateCompat.ACTION_SET_REPEAT_MODE + | PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE + | PlaybackStateCompat.ACTION_SET_CAPTIONING_ENABLED; + + static AudioService instance; + private static PendingIntent contentIntent; + private static ServiceListener listener; + private static List queue = new ArrayList<>(); + private static final Map mediaMetadataCache = new HashMap<>(); + + public static void init(ServiceListener listener) { + AudioService.listener = listener; + } + + 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); + } + } + + MediaMetadataCompat createMediaMetadata(String mediaId, String title, String album, String artist, String genre, Long duration, String artUri, Boolean playable, 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_TITLE, title); + if (album != null) + builder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album); + 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); + } + if (playable != null) + builder.putLong("playable_long", playable ? 1 : 0); + 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(key, (Long)value); + } else if (value instanceof Integer) { + builder.putLong(key, (long)((Integer)value)); + } else if (value instanceof String) { + builder.putString(key, (String)value); + } else if (value instanceof Boolean) { + builder.putLong(key, (Boolean)value ? 1 : 0); + } else if (value instanceof Double) { + builder.putString(key, value.toString()); + } + } + } + MediaMetadataCompat mediaMetadata = builder.build(); + mediaMetadataCache.put(mediaId, mediaMetadata); + return mediaMetadata; + } + + static MediaMetadataCompat getMediaMetadata(String mediaId) { + return mediaMetadataCache.get(mediaId); + } + + Bitmap loadArtBitmap(String artUriString, String loadThumbnailUri) { + Bitmap bitmap = artBitmapCache.get(artUriString); + if (bitmap != null) return bitmap; + try { + // There are 3 cases handled by this function: + // 1. content URI with openFileDescriptor + // 2. content URI with loadThumbnail (when Android >= Q and specified by the config) + // 3. not content URI - loading from the file, or cache file created by the Dart side + Uri artUri = Uri.parse(artUriString); + boolean usesContentScheme = "content".equals(artUri.getScheme()); + FileDescriptor fileDescriptor = null; + if (usesContentScheme) { + try { + if (loadThumbnailUri != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Size defaultSize = new Size(192, 192); + bitmap = getContentResolver().loadThumbnail( + artUri, + new Size(config.artDownscaleWidth == -1 + ? defaultSize.getWidth() + : config.artDownscaleWidth, + config.artDownscaleHeight == -1 + ? defaultSize.getHeight() + : config.artDownscaleHeight), + null); + if (bitmap == null) { + return null; + } + } else { + ParcelFileDescriptor parcelFileDescriptor = getContentResolver().openFileDescriptor(artUri, "r"); + if (parcelFileDescriptor != null) { + fileDescriptor = parcelFileDescriptor.getFileDescriptor(); + } else { + return null; + } + } + } catch (FileNotFoundException ex) { + return null; + } catch (IOException ex) { + return null; + } + } + // Decode the image ourselves for scenarios 1 and 3 (see the comment above). + if (!usesContentScheme || fileDescriptor != null) { + if (config.artDownscaleWidth != -1) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + if (fileDescriptor != null) { + BitmapFactory.decodeFileDescriptor(fileDescriptor, null, options); + } else { + BitmapFactory.decodeFile(artUri.getPath(), options); + } + options.inSampleSize = calculateInSampleSize(options, config.artDownscaleWidth, config.artDownscaleHeight); + options.inJustDecodeBounds = false; + + if (fileDescriptor != null) { + bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor, null, options); + } else { + bitmap = BitmapFactory.decodeFile(artUri.getPath(), options); + } + } else { + if (fileDescriptor != null) { + bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor); + } else { + bitmap = BitmapFactory.decodeFile(artUri.getPath()); + } + } + } + artBitmapCache.put(artUriString, 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; + } + + private FlutterEngine flutterEngine; + private AudioServiceConfig config; + private PowerManager.WakeLock wakeLock; + private MediaSessionCompat mediaSession; + private MediaSessionCallback mediaSessionCallback; + private List controls = new ArrayList<>(); + private List nativeActions = new ArrayList<>(); + private List customActions = new ArrayList<>(); + private int[] compactActionIndices; + private MediaMetadataCompat mediaMetadata; + private Bitmap artBitmap; + private String notificationChannelId; + private LruCache artBitmapCache; + private boolean playing = false; + private AudioProcessingState processingState = AudioProcessingState.idle; + private int repeatMode; + private int shuffleMode; + private boolean notificationCreated; + private final Handler handler = new Handler(Looper.getMainLooper()); + private VolumeProviderCompat volumeProvider; + + public AudioProcessingState getProcessingState() { + return processingState; + } + + public boolean isPlaying() { + return playing; + } + + public int getRepeatMode() { + return repeatMode; + } + + public int getShuffleMode() { + return shuffleMode; + } + + @Override + public void onCreate() { + super.onCreate(); + instance = this; + repeatMode = 0; + shuffleMode = 0; + notificationCreated = false; + playing = false; + processingState = AudioProcessingState.idle; + mediaSession = new MediaSessionCompat(this, "media-session"); + + configure(new AudioServiceConfig(getApplicationContext())); + + mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_QUEUE_COMMANDS); + PlaybackStateCompat.Builder stateBuilder = new PlaybackStateCompat.Builder() + .setActions(AUTO_ENABLED_ACTIONS); + 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()); + + // 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; + } + }; + + flutterEngine = AudioServicePlugin.getFlutterEngine(this); + System.out.println("flutterEngine warmed up"); + } + + @Override + public int onStartCommand(final Intent intent, int flags, int startId) { + MediaButtonReceiver.handleIntent(mediaSession, intent); + return START_NOT_STICKY; + } + + public void stop() { + deactivateMediaSession(); + stopSelf(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (listener != null) { + listener.onDestroy(); + listener = null; + } + mediaMetadata = null; + artBitmap = null; + queue.clear(); + mediaMetadataCache.clear(); + controls.clear(); + artBitmapCache.evictAll(); + compactActionIndices = null; + releaseMediaSession(); + ServiceCompat.stopForeground(this, config.androidResumeOnClick ? STOP_FOREGROUND_DETACH : STOP_FOREGROUND_REMOVE); + // This still does not solve the Android 11 problem. + // if (notificationCreated) { + // NotificationManager notificationManager = getNotificationManager(); + // notificationManager.cancel(NOTIFICATION_ID); + // } + releaseWakeLock(); + instance = null; + notificationCreated = false; + } + + public AudioServiceConfig getConfig() { + return config; + } + + public void configure(AudioServiceConfig config) { + this.config = config; + notificationChannelId = (config.androidNotificationChannelId != null) + ? config.androidNotificationChannelId + : getApplication().getPackageName() + ".channel"; + + if (config.activityClassName != null) { + Context context = getApplicationContext(); + Intent intent = new Intent((String)null); + intent.setComponent(new ComponentName(context, config.activityClassName)); + //Intent intent = new Intent(context, config.activityClassName); + intent.setAction(NOTIFICATION_CLICK_ACTION); + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= 23) { + flags |= PendingIntent.FLAG_IMMUTABLE; + } + contentIntent = PendingIntent.getActivity(context, REQUEST_CONTENT_INTENT, intent, flags); + } else { + contentIntent = null; + } + if (!config.androidResumeOnClick) { + mediaSession.setMediaButtonReceiver(null); + } + } + + 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 createAction(String resource, String label, long actionCode) { + int iconId = getResourceId(resource); + return new NotificationCompat.Action(iconId, label, + buildMediaButtonPendingIntent(actionCode)); + } + + private Intent customActionActivity(MediaControl control) { + Bundle extras = mapToBundle(control.customAction.extras); + String activityClass = extras == null ? null : extras.getString("spotiflac.activity"); + if (activityClass == null) return null; + // setClassName keeps the component inside this application's package. + return new Intent(control.customAction.name) + .setClassName(this, activityClass) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + } + + private PendingIntent customActionIntent(MediaControl control) { + Bundle extras = mapToBundle(control.customAction.extras); + Intent activityIntent = customActionActivity(control); + int flags = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE; + if (activityIntent != null) { + return PendingIntent.getActivity(this, control.customAction.name.hashCode(), activityIntent, flags); + } + Intent intent = new Intent(this, MediaButtonReceiver.class) + .setAction(MediaButtonReceiver.ACTION_CUSTOM) + .putExtra("action", control.customAction.name) + .putExtra("extras", extras); + return PendingIntent.getBroadcast(this, control.customAction.name.hashCode(), intent, flags); + } + + void handleCustomNotificationAction(String action, Bundle extras) { + if (action != null) mediaSessionCallback.onCustomAction(action, extras); + } + + private boolean needCustomMediaControl(MediaControl control) { + return control.customAction != null; + } + + private Bundle mapToBundle(Map map) { + if (map == null) { + return null; + } + Bundle bundle = new Bundle(); + for (Map.Entry entry : map.entrySet()) { + String key = entry.getKey().toString(); + Object value = entry.getValue(); + if (value instanceof Integer) { + bundle.putInt(key, (Integer)value); + } else if (value instanceof Long) { + bundle.putLong(key, (Long)value); + } else { + bundle.putString(key, value.toString()); + } + } + return bundle; + } + + PlaybackStateCompat.CustomAction createCustomAction(MediaControl control) { + int iconId = getResourceId(control.icon); + if (control.customAction != null) { + return new PlaybackStateCompat.CustomAction.Builder(control.customAction.name, control.label, iconId) + .setExtras(mapToBundle(control.customAction.extras)) + .build(); + } else if (Build.VERSION.SDK_INT >= 33) { + // Android 13 changes MediaControl behavior as documented here: + // https://developer.android.com/about/versions/13/behavior-changes-13 + // The below actions will be added to slots 1-3, if included. + // 1 - ACTION_PLAY, ACTION_PLAY + // 2 - ACTION_SKIP_TO_PREVIOUS + // 3 - ACTION_SKIP_TO_NEXT + // Custom actions will use slots 2-5 if included. + // - ACTION_STOP + // - ACTION_FAST_FORWARD + // - ACTION_REWIND + if (control.actionCode == PlaybackStateCompat.ACTION_STOP) { + return new PlaybackStateCompat.CustomAction.Builder(CUSTOM_ACTION_STOP, control.label, iconId).build(); + } else if (control.actionCode == PlaybackStateCompat.ACTION_FAST_FORWARD) { + return new PlaybackStateCompat.CustomAction.Builder(CUSTOM_ACTION_FAST_FORWARD, control.label, iconId).build(); + } else if (control.actionCode == PlaybackStateCompat.ACTION_REWIND) { + return new PlaybackStateCompat.CustomAction.Builder(CUSTOM_ACTION_REWIND, control.label, iconId).build(); + } + } + return null; + } + + 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)); + int flags = 0; + if (Build.VERSION.SDK_INT >= 23) { + flags |= PendingIntent.FLAG_IMMUTABLE; + } + return PendingIntent.getBroadcast(this, keyCode, intent, flags); + } + + PendingIntent buildDeletePendingIntent() { + Intent intent = new Intent(this, MediaButtonReceiver.class); + intent.setAction(MediaButtonReceiver.ACTION_NOTIFICATION_DELETE); + int flags = 0; + if (Build.VERSION.SDK_INT >= 23) { + flags |= PendingIntent.FLAG_IMMUTABLE; + } + return PendingIntent.getBroadcast(this, 0, intent, flags); + } + + void setState(List controls, long actionBits, int[] compactActionIndices, AudioProcessingState processingState, boolean playing, long position, long bufferedPosition, float speed, long updateTime, Integer errorCode, String errorMessage, int repeatMode, int shuffleMode, boolean captioningEnabled, Long queueIndex) { + boolean notificationChanged = false; + if (!Arrays.equals(compactActionIndices, this.compactActionIndices)) { + notificationChanged = true; + } + if (!controls.equals(this.controls)) { + notificationChanged = true; + } + this.controls = controls; + this.nativeActions.clear(); + this.customActions.clear(); + for (MediaControl control : controls) { + final PlaybackStateCompat.CustomAction customAction = createCustomAction(control); + if (customAction != null) { + // SystemUI sends PlaybackState custom actions through a binder + // callback, which cannot launch an activity from the background. + // Android 13+ supplies its own output switcher. Activity actions + // remain direct PendingIntents in legacy MediaStyle notifications. + if (control.customAction == null || customActionActivity(control) == null) { + customActions.add(customAction); + } + if (control.customAction != null) { + nativeActions.add(new NotificationCompat.Action( + getResourceId(control.icon), control.label, customActionIntent(control))); + } + } else { + nativeActions.add(createAction(control.icon, control.label, control.actionCode)); + } + } + this.compactActionIndices = compactActionIndices; + boolean wasPlaying = this.playing; + AudioProcessingState oldProcessingState = this.processingState; + this.processingState = processingState; + this.playing = playing; + this.repeatMode = repeatMode; + this.shuffleMode = shuffleMode; + + PlaybackStateCompat.Builder stateBuilder = new PlaybackStateCompat.Builder() + .setActions(AUTO_ENABLED_ACTIONS | actionBits) + .setState(getPlaybackState(), position, speed, updateTime) + .setBufferedPosition(bufferedPosition); + + for (PlaybackStateCompat.CustomAction action : this.customActions) { + stateBuilder.addCustomAction(action); + } + + if (queueIndex != null) + stateBuilder.setActiveQueueItemId(queueIndex); + if (errorCode != null && errorMessage != null) + stateBuilder.setErrorMessage(errorCode, errorMessage); + else if (errorMessage != null) + stateBuilder.setErrorMessage(-987654, errorMessage); + + if (mediaMetadata != null) { + // Update the progress bar in the browse view as content is playing as explained + // here: https://developer.android.com/training/cars/media#browse-progress-bar + Bundle extras = new Bundle(); + extras.putString(MediaConstants.PLAYBACK_STATE_EXTRAS_KEY_MEDIA_ID, mediaMetadata.getDescription().getMediaId()); + stateBuilder.setExtras(extras); + } + + mediaSession.setPlaybackState(stateBuilder.build()); + mediaSession.setRepeatMode(repeatMode); + mediaSession.setShuffleMode(shuffleMode); + mediaSession.setCaptioningEnabled(captioningEnabled); + + if (!wasPlaying && playing) { + enterPlayingState(); + } else if (wasPlaying && !playing) { + exitPlayingState(); + } + + if (oldProcessingState != AudioProcessingState.idle && processingState == AudioProcessingState.idle) { + // TODO: Handle completed state as well? + stop(); + } else if (processingState != AudioProcessingState.idle && notificationChanged) { + updateNotification(); + } + } + + public void setPlaybackInfo(int playbackType, Integer volumeControlType, Integer maxVolume, Integer volume) { + if (playbackType == MediaControllerCompat.PlaybackInfo.PLAYBACK_TYPE_LOCAL) { + // We have to wait 'til media2 before we can use AudioAttributes. + mediaSession.setPlaybackToLocal(AudioManager.STREAM_MUSIC); + volumeProvider = null; + } else if (playbackType == MediaControllerCompat.PlaybackInfo.PLAYBACK_TYPE_REMOTE) { + if (volumeProvider == null || volumeControlType != volumeProvider.getVolumeControl() || maxVolume != volumeProvider.getMaxVolume()) { + volumeProvider = new VolumeProviderCompat(volumeControlType, maxVolume, volume) { + @Override + public void onSetVolumeTo(int volumeIndex) { + if (listener == null) return; + listener.onSetVolumeTo(volumeIndex); + } + @Override + public void onAdjustVolume(int direction) { + if (listener == null) return; + listener.onAdjustVolume(direction); + } + }; + } else { + volumeProvider.setCurrentVolume(volume); + } + mediaSession.setPlaybackToRemote(volumeProvider); + } else { + // silently ignore + } + } + + public int getPlaybackState() { + switch (processingState) { + case idle: return PlaybackStateCompat.STATE_NONE; + case loading: return PlaybackStateCompat.STATE_CONNECTING; + case buffering: return PlaybackStateCompat.STATE_BUFFERING; + case ready: return playing ? PlaybackStateCompat.STATE_PLAYING : PlaybackStateCompat.STATE_PAUSED; + case completed: return playing ? PlaybackStateCompat.STATE_PLAYING : PlaybackStateCompat.STATE_PAUSED; + 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, nativeActions.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()); + synchronized (this) { + if (artBitmap != null) + builder.setLargeIcon(artBitmap); + } + } + if (config.androidNotificationClickStartsActivity) + builder.setContentIntent(mediaSession.getController().getSessionActivity()); + // TODO: Look at setColorized + if (config.notificationColor != -1) + builder.setColor(config.notificationColor); + for (NotificationCompat.Action action : nativeActions) { + builder.addAction(action); + } + final MediaStyle style = new MediaStyle() + .setMediaSession(mediaSession.getSessionToken()); + if (Build.VERSION.SDK_INT < 33) { + style.setShowActionsInCompactView(compactActionIndices); + } + if (config.androidNotificationOngoing) { + style.setShowCancelButton(true); + style.setCancelButtonIntent(buildMediaButtonPendingIntent(PlaybackStateCompat.ACTION_STOP)); + builder.setOngoing(true); + } + builder.setStyle(style); + return builder.build(); + } + + private NotificationManager getNotificationManager() { + return (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE); + } + + private /*synchronized*/ NotificationCompat.Builder getNotificationBuilder() { + // This local variable could be commented out and replaced by an + // instance variable if we want to reuse the builder instance. However, + // there doesn't turn out to be much benefit to this since we don't + // actually reuse any of the previous notification values when setting + // a new notification. + NotificationCompat.Builder notificationBuilder = null; + if (notificationBuilder == null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + createChannel(); + notificationBuilder = new NotificationCompat.Builder(this, notificationChannelId) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setShowWhen(false) + .setDeleteIntent(buildDeletePendingIntent()) + ; + } + int iconId = getResourceId(config.androidNotificationIcon); + notificationBuilder.setSmallIcon(iconId); + return notificationBuilder; + } + + public void handleDeleteNotification() { + if (listener == null) return; + listener.onClose(); + } + + + @RequiresApi(Build.VERSION_CODES.O) + private void createChannel() { + NotificationManager notificationManager = getNotificationManager(); + NotificationChannel channel = notificationManager.getNotificationChannel(notificationChannelId); + if (channel == null) { + channel = new NotificationChannel(notificationChannelId, config.androidNotificationChannelName, NotificationManager.IMPORTANCE_LOW); + channel.setShowBadge(config.androidShowNotificationBadge); + if (config.androidNotificationChannelDescription != null) + channel.setDescription(config.androidNotificationChannelDescription); + notificationManager.createNotificationChannel(channel); + } + } + + private void updateNotification() { + if (notificationCreated) { + getNotificationManager().notify(NOTIFICATION_ID, buildNotification()); + } + } + + private void enterPlayingState() { + ContextCompat.startForegroundService(this, new Intent(AudioService.this, AudioService.class)); + if (!mediaSession.isActive()) + mediaSession.setActive(true); + + acquireWakeLock(); + mediaSession.setSessionActivity(contentIntent); + internalStartForeground(); + } + + private void exitPlayingState() { + if (config.androidStopForegroundOnPause) { + exitForegroundState(); + } + } + + private void exitForegroundState() { + ServiceCompat.stopForeground(this, STOP_FOREGROUND_DETACH); + 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(); + } + + private void activateMediaSession() { + if (!mediaSession.isActive()) + mediaSession.setActive(true); + } + + private void deactivateMediaSession() { + if (mediaSession.isActive()) { + mediaSession.setActive(false); + } + // Force cancellation of the notification + getNotificationManager().cancel(NOTIFICATION_ID); + } + + private void releaseMediaSession() { + if (mediaSession == null) return; + deactivateMediaSession(); + mediaSession.release(); + mediaSession = null; + } + + /** + * Updates queue. + * Gets called from background thread. + */ + synchronized void setQueue(List queue) { + AudioService.queue = queue; + mediaSession.setQueue(queue); + } + + void playMediaItem(MediaDescriptionCompat description) { + mediaSessionCallback.onPlayMediaItem(description); + } + + /** + * Updates metadata, loads the art and updates the notification. + * Gets called from background thread. + *

+ * Also adds the loaded art bitmap to the MediaMetadata. + * This is needed to display art in lock screen in versions + * prior Android 11, in which this feature was removed. + *

+ * See: + * - https://developer.android.com/guide/topics/media-apps/working-with-a-media-session#album_artwork + * - https://9to5google.com/2020/08/02/android-11-lockscreen-art/ + */ + synchronized void setMetadata(MediaMetadataCompat mediaMetadata) { + String artCacheFilePath = mediaMetadata.getString("artCacheFile"); + if (artCacheFilePath != null) { + // Load local files and network images, cached in files + artBitmap = loadArtBitmap(artCacheFilePath, null); + mediaMetadata = putArtToMetadata(mediaMetadata); + } else { + // Load content:// URIs + String artUri = mediaMetadata.getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI); + if (artUri != null && artUri.startsWith("content:")) { + String loadThumbnailUri = mediaMetadata.getString("loadThumbnailUri"); + artBitmap = loadArtBitmap(artUri, loadThumbnailUri); + mediaMetadata = putArtToMetadata(mediaMetadata); + } else { + artBitmap = null; + } + } + this.mediaMetadata = mediaMetadata; + mediaSession.setMetadata(mediaMetadata); + handler.removeCallbacksAndMessages(null); + handler.post(this::updateNotification); + } + + private MediaMetadataCompat putArtToMetadata(MediaMetadataCompat mediaMetadata) { + return new MediaMetadataCompat.Builder(mediaMetadata) + .putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, artBitmap) + .putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, artBitmap) + .build(); + } + + @Override + public BrowserRoot onGetRoot(String clientPackageName, int clientUid, Bundle rootHints) { + Boolean isRecentRequest = rootHints == null ? null : (Boolean)rootHints.getBoolean(BrowserRoot.EXTRA_RECENT); + if (isRecentRequest == null) isRecentRequest = false; + Bundle extras = config.getBrowsableRootExtras(); + return new BrowserRoot(isRecentRequest ? RECENT_ROOT_ID : BROWSABLE_ROOT_ID, extras); + // The response must be given synchronously, and we can't get a + // synchronous response from the Dart layer. For now, we hardcode + // the root to "root". This may improve in media2. + //return listener.onGetRoot(clientPackageName, clientUid, rootHints); + } + + @Override + public void onLoadChildren(final String parentMediaId, final Result> result) { + onLoadChildren(parentMediaId, result, null); + } + + @Override + public void onLoadChildren(final String parentMediaId, final Result> result, Bundle options) { + if (listener == null) { + result.sendResult(new ArrayList<>()); + return; + } + listener.onLoadChildren(parentMediaId, result, options); + } + + @Override + public void onLoadItem(String itemId, Result result) { + if (listener == null) { + result.sendResult(null); + return; + } + listener.onLoadItem(itemId, result); + } + + @Override + public void onSearch(String query, Bundle extras, Result> result) { + if (listener == null) { + result.sendResult(new ArrayList<>()); + return; + } + listener.onSearch(query, extras, result); + } + + @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 onPrepareFromMediaId(String mediaId, Bundle extras) { + if (listener == null) return; + if (!mediaSession.isActive()) + mediaSession.setActive(true); + listener.onPrepareFromMediaId(mediaId, extras); + } + + @Override + public void onPrepareFromSearch(String query, Bundle extras) { + if (listener == null) return; + if (!mediaSession.isActive()) + mediaSession.setActive(true); + listener.onPrepareFromSearch(query, extras); + } + + @Override + public void onPrepareFromUri(Uri uri, Bundle extras) { + if (listener == null) return; + if (!mediaSession.isActive()) + mediaSession.setActive(true); + listener.onPrepareFromUri(uri, extras); + } + + @Override + public void onPlay() { + if (listener == null) return; + listener.onPlay(); + } + + @Override + public void onPlayFromMediaId(final String mediaId, final Bundle extras) { + if (listener == null) return; + listener.onPlayFromMediaId(mediaId, extras); + } + + @Override + public void onPlayFromSearch(final String query, final Bundle extras) { + if (listener == null) return; + listener.onPlayFromSearch(query, extras); + } + + @Override + public void onPlayFromUri(final Uri uri, final Bundle extras) { + if (listener == null) return; + listener.onPlayFromUri(uri, extras); + } + + @Override + public boolean onMediaButtonEvent(Intent mediaButtonEvent) { + if (listener == null) return false; + // TODO: use typesafe version once SDK 33 is released. + @SuppressWarnings("deprecation") + final KeyEvent event = (KeyEvent)mediaButtonEvent.getExtras().getParcelable(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: + listener.onClick(eventToButton(event)); + break; + } + } + return true; + } + + private MediaButton eventToButton(KeyEvent event) { + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: + case KeyEvent.KEYCODE_HEADSETHOOK: + return MediaButton.media; + case KeyEvent.KEYCODE_MEDIA_NEXT: + return MediaButton.next; + case KeyEvent.KEYCODE_MEDIA_PREVIOUS: + return MediaButton.previous; + default: + return MediaButton.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 onSetPlaybackSpeed(float speed) { + if (listener == null) return; + listener.onSetPlaybackSpeed(speed); + } + + @Override + public void onSetCaptioningEnabled(boolean enabled) { + if (listener == null) return; + listener.onSetCaptioningEnabled(enabled); + } + + @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 onCustomAction(String action, Bundle extras) { + if (listener == null) return; + if (CUSTOM_ACTION_STOP.equals(action)) { + listener.onStop(); + } else if (CUSTOM_ACTION_FAST_FORWARD.equals(action)) { + listener.onFastForward(); + } else if (CUSTOM_ACTION_REWIND.equals(action)) { + listener.onRewind(); + } else { + listener.onCustomAction(action, extras); + } + } + + @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 interface ServiceListener { + //BrowserRoot onGetRoot(String clientPackageName, int clientUid, Bundle rootHints); + void onLoadChildren(String parentMediaId, Result> result, Bundle options); + void onLoadItem(String itemId, Result result); + void onSearch(String query, Bundle extras, Result> result); + void onClick(MediaButton mediaButton); + void onPrepare(); + void onPrepareFromMediaId(String mediaId, Bundle extras); + void onPrepareFromSearch(String query, Bundle extras); + void onPrepareFromUri(Uri uri, Bundle extras); + void onPlay(); + void onPlayFromMediaId(String mediaId, Bundle extras); + void onPlayFromSearch(String query, Bundle extras); + void onPlayFromUri(Uri uri, Bundle extras); + void onSkipToQueueItem(long id); + void onPause(); + void onSkipToNext(); + void onSkipToPrevious(); + void onFastForward(); + void onRewind(); + void onStop(); + void onSeekTo(long pos); + void onSetRating(RatingCompat rating); + void onSetRating(RatingCompat rating, Bundle extras); + void onSetRepeatMode(int repeatMode); + void onSetShuffleMode(int shuffleMode); + void onCustomAction(String action, Bundle extras); + void onAddQueueItem(MediaMetadataCompat metadata); + void onAddQueueItemAt(MediaMetadataCompat metadata, int index); + void onRemoveQueueItem(MediaMetadataCompat metadata); + void onRemoveQueueItemAt(int index); + void onSetPlaybackSpeed(float speed); + void onSetCaptioningEnabled(boolean enabled); + void onSetVolumeTo(int volumeIndex); + void onAdjustVolume(int direction); + + // + // NON-STANDARD METHODS + // + + void onPlayMediaItem(MediaMetadataCompat metadata); + void onTaskRemoved(); + void onClose(); + void onDestroy(); + } +} diff --git a/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServiceActivity.java b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServiceActivity.java new file mode 100644 index 00000000..f91f663b --- /dev/null +++ b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServiceActivity.java @@ -0,0 +1,15 @@ +package com.ryanheise.audioservice; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; + +public class AudioServiceActivity extends FlutterActivity { + @Override + public FlutterEngine provideFlutterEngine(@NonNull Context context) { + return AudioServicePlugin.getFlutterEngine(context); + } +} diff --git a/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServiceConfig.java b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServiceConfig.java new file mode 100644 index 00000000..1917989d --- /dev/null +++ b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServiceConfig.java @@ -0,0 +1,120 @@ +package com.ryanheise.audioservice; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Bundle; +import java.util.Iterator; +import java.util.Map; +import org.json.JSONObject; + +public class AudioServiceConfig { + private static final String SHARED_PREFERENCES_NAME = "audio_service_preferences"; + private static final String KEY_ANDROID_RESUME_ON_CLICK = "androidResumeOnClick"; + private static final String KEY_ANDROID_NOTIFICATION_CHANNEL_ID = "androidNotificationChannelId"; + private static final String KEY_ANDROID_NOTIFICATION_CHANNEL_NAME = "androidNotificationChannelName"; + private static final String KEY_ANDROID_NOTIFICATION_CHANNEL_DESCRIPTION = "androidNotificationChannelDescription"; + private static final String KEY_NOTIFICATION_COLOR = "notificationColor"; + private static final String KEY_ANDROID_NOTIFICATION_ICON = "androidNotificationIcon"; + private static final String KEY_ANDROID_SHOW_NOTIFICATION_BADGE = "androidShowNotificationBadge"; + private static final String KEY_ANDROID_NOTIFICATION_CLICK_STARTS_ACTIVITY = "androidNotificationClickStartsActivity"; + private static final String KEY_ANDROID_NOTIFICATION_ONGOING = "androidNotificationOngoing"; + private static final String KEY_ANDROID_STOP_FOREGROUND_ON_PAUSE = "androidStopForegroundOnPause"; + private static final String KEY_ART_DOWNSCALE_WIDTH = "artDownscaleWidth"; + private static final String KEY_ART_DOWNSCALE_HEIGHT = "artDownscaleHeight"; + private static final String KEY_ACTIVITY_CLASS_NAME = "activityClassName"; + private static final String KEY_BROWSABLE_ROOT_EXTRAS = "androidBrowsableRootExtras"; + + private SharedPreferences preferences; + public boolean androidResumeOnClick; + public String androidNotificationChannelId; + public String androidNotificationChannelName; + public String androidNotificationChannelDescription; + public int notificationColor; + public String androidNotificationIcon; + public boolean androidShowNotificationBadge; + public boolean androidNotificationClickStartsActivity; + public boolean androidNotificationOngoing; + public boolean androidStopForegroundOnPause; + public int artDownscaleWidth; + public int artDownscaleHeight; + public String activityClassName; + public String browsableRootExtras; + + public AudioServiceConfig(Context context) { + preferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE); + androidResumeOnClick = preferences.getBoolean(KEY_ANDROID_RESUME_ON_CLICK, true); + androidNotificationChannelId = preferences.getString(KEY_ANDROID_NOTIFICATION_CHANNEL_ID, null); + androidNotificationChannelName = preferences.getString(KEY_ANDROID_NOTIFICATION_CHANNEL_NAME, null); + androidNotificationChannelDescription = preferences.getString(KEY_ANDROID_NOTIFICATION_CHANNEL_DESCRIPTION, null); + notificationColor = preferences.getInt(KEY_NOTIFICATION_COLOR, -1); + androidNotificationIcon = preferences.getString(KEY_ANDROID_NOTIFICATION_ICON, "mipmap/ic_launcher"); + androidShowNotificationBadge = preferences.getBoolean(KEY_ANDROID_SHOW_NOTIFICATION_BADGE, false); + androidNotificationClickStartsActivity = preferences.getBoolean(KEY_ANDROID_NOTIFICATION_CLICK_STARTS_ACTIVITY, true); + androidNotificationOngoing = preferences.getBoolean(KEY_ANDROID_NOTIFICATION_ONGOING, false); + androidStopForegroundOnPause = preferences.getBoolean(KEY_ANDROID_STOP_FOREGROUND_ON_PAUSE, true); + artDownscaleWidth = preferences.getInt(KEY_ART_DOWNSCALE_WIDTH, -1); + artDownscaleHeight = preferences.getInt(KEY_ART_DOWNSCALE_HEIGHT, -1); + activityClassName = preferences.getString(KEY_ACTIVITY_CLASS_NAME, null); + browsableRootExtras = preferences.getString(KEY_BROWSABLE_ROOT_EXTRAS, null); + } + + public void setBrowsableRootExtras(Map map) { + if (map != null) { + JSONObject json = new JSONObject(map); + browsableRootExtras = json.toString(); + } else { + browsableRootExtras = null; + } + } + + public Bundle getBrowsableRootExtras() { + if (browsableRootExtras == null) return null; + try { + JSONObject json = new JSONObject(browsableRootExtras); + Bundle extras = new Bundle(); + for (Iterator it = json.keys(); it.hasNext();) { + String key = it.next(); + try { + extras.putInt(key, json.getInt(key)); + } catch (Exception e1) { + try { + extras.putBoolean(key, json.getBoolean(key)); + } catch (Exception e2) { + try { + extras.putDouble(key, json.getDouble(key)); + } catch (Exception e3) { + try { + extras.putString(key, json.getString(key)); + } catch (Exception e4) { + System.out.println("Unsupported extras value for key " + key); + } + } + } + } + } + return extras; + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + public void save() { + preferences.edit() + .putBoolean(KEY_ANDROID_RESUME_ON_CLICK, androidResumeOnClick) + .putString(KEY_ANDROID_NOTIFICATION_CHANNEL_ID, androidNotificationChannelId) + .putString(KEY_ANDROID_NOTIFICATION_CHANNEL_NAME, androidNotificationChannelName) + .putString(KEY_ANDROID_NOTIFICATION_CHANNEL_DESCRIPTION, androidNotificationChannelDescription) + .putInt(KEY_NOTIFICATION_COLOR, notificationColor) + .putString(KEY_ANDROID_NOTIFICATION_ICON, androidNotificationIcon) + .putBoolean(KEY_ANDROID_SHOW_NOTIFICATION_BADGE, androidShowNotificationBadge) + .putBoolean(KEY_ANDROID_NOTIFICATION_CLICK_STARTS_ACTIVITY, androidNotificationClickStartsActivity) + .putBoolean(KEY_ANDROID_NOTIFICATION_ONGOING, androidNotificationOngoing) + .putBoolean(KEY_ANDROID_STOP_FOREGROUND_ON_PAUSE, androidStopForegroundOnPause) + .putInt(KEY_ART_DOWNSCALE_WIDTH, artDownscaleWidth) + .putInt(KEY_ART_DOWNSCALE_HEIGHT, artDownscaleHeight) + .putString(KEY_ACTIVITY_CLASS_NAME, activityClassName) + .putString(KEY_BROWSABLE_ROOT_EXTRAS, browsableRootExtras) + .apply(); + } +} diff --git a/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServiceFragmentActivity.java b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServiceFragmentActivity.java new file mode 100644 index 00000000..723fbbaa --- /dev/null +++ b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServiceFragmentActivity.java @@ -0,0 +1,48 @@ +package com.ryanheise.audioservice; + +import android.content.Context; + +import android.os.Bundle; +import androidx.annotation.NonNull; + +import androidx.annotation.Nullable; +import io.flutter.embedding.android.FlutterFragmentActivity; +import io.flutter.embedding.engine.FlutterEngine; + +public class AudioServiceFragmentActivity extends FlutterFragmentActivity { + @Override + public FlutterEngine provideFlutterEngine(@NonNull Context context) { + return AudioServicePlugin.getFlutterEngine(context); + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + AudioServicePlugin.getFlutterEngine(this); + super.onCreate(savedInstanceState); + } + + @Override + protected String getCachedEngineId() { + AudioServicePlugin.getFlutterEngine(this); + return AudioServicePlugin.getFlutterEngineId(); + } + + // The engine is created and managed by AudioServicePlugin, + // it should not be destroyed with the activity. + @Override + public boolean shouldDestroyEngineWithHost() { + return false; + } + + // Make this protected method public + @Override + public String getInitialRoute() { + return super.getInitialRoute(); + } + + // Make this protected method public + @Override + public boolean shouldHandleDeeplinking() { + return super.shouldHandleDeeplinking(); + } +} diff --git a/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java new file mode 100644 index 00000000..b776a840 --- /dev/null +++ b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/AudioServicePlugin.java @@ -0,0 +1,1254 @@ +package com.ryanheise.audioservice; + +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.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; + +import androidx.annotation.UiThread; +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.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executors; + +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.android.FlutterFragmentActivity; +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; +import io.flutter.embedding.engine.plugins.activity.ActivityAware; +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.BinaryMessenger; + +import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.embedding.engine.FlutterEngineCache; +import io.flutter.embedding.engine.dart.DartExecutor; + +import android.net.Uri; +import android.util.Log; + +/** + * AudioservicePlugin + */ +public class AudioServicePlugin implements FlutterPlugin, ActivityAware { + private static String flutterEngineId = "audio_service_engine"; + /** Must be called BEFORE any FlutterEngine is created. e.g. in Application class. */ + public static void setFlutterEngineId(String id) { + flutterEngineId = id; + } + public static String getFlutterEngineId() { + return flutterEngineId; + } + public static synchronized FlutterEngine getFlutterEngine(Context context) { + FlutterEngine flutterEngine = FlutterEngineCache.getInstance().get(flutterEngineId); + if (flutterEngine == null) { + // XXX: The constructor triggers onAttachedToEngine so this variable doesn't help us. + // Maybe need a boolean flag to tell us we're currently loading the main flutter engine. + flutterEngine = new FlutterEngine(context.getApplicationContext()); + String initialRoute = null; + if (context instanceof FlutterActivity) { + final FlutterActivity activity = (FlutterActivity)context; + initialRoute = activity.getInitialRoute(); + if (initialRoute == null) { + if (activity.shouldHandleDeeplinking()) { + Uri data = activity.getIntent().getData(); + if (data != null) { + initialRoute = data.getPath(); + if (data.getQuery() != null && !data.getQuery().isEmpty()) { + initialRoute += "?" + data.getQuery(); + } + } + } + } + } else if (context instanceof AudioServiceFragmentActivity) { + final AudioServiceFragmentActivity activity = (AudioServiceFragmentActivity)context; + initialRoute = activity.getInitialRoute(); + if (initialRoute == null) { + if (activity.shouldHandleDeeplinking()) { + Uri data = activity.getIntent().getData(); + if (data != null) { + initialRoute = data.getPath(); + if (data.getQuery() != null && !data.getQuery().isEmpty()) { + initialRoute += "?" + data.getQuery(); + } + } + } + } + } + if (initialRoute == null) { + initialRoute = "/"; + } + flutterEngine.getNavigationChannel().setInitialRoute(initialRoute); + flutterEngine.getDartExecutor().executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault()); + FlutterEngineCache.getInstance().put(flutterEngineId, flutterEngine); + } + return flutterEngine; + } + + public static synchronized void disposeFlutterEngine() { + for (ClientInterface clientInterface : clientInterfaces) { + if (clientInterface.activity != null) { + // Don't destroy the engine if a new activity started and + // bound to the service in the time since the previous activity + // unbound from it. + return; + } + } + FlutterEngine flutterEngine = FlutterEngineCache.getInstance().get(flutterEngineId); + if (flutterEngine != null) { + flutterEngine.destroy(); + FlutterEngineCache.getInstance().remove(flutterEngineId); + } + } + + private static final String CHANNEL_CLIENT = "com.ryanheise.audio_service.client.methods"; + private static final String CHANNEL_HANDLER = "com.ryanheise.audio_service.handler.methods"; + + private static final Set clientInterfaces = new HashSet<>(); + private static ClientInterface mainClientInterface; + private static AudioHandlerInterface audioHandlerInterface; + private static final long bootTime; + private static Result configureResult; + private static boolean flutterReady; + + static { + bootTime = System.currentTimeMillis() - SystemClock.elapsedRealtime(); + } + + static AudioHandlerInterface audioHandlerInterface() throws Exception { + if (audioHandlerInterface == null) throw new Exception("Background audio task not running"); + return audioHandlerInterface; + } + + private static MediaBrowserCompat mediaBrowser; + private static MediaControllerCompat mediaController; + private static final MediaControllerCompat.Callback controllerCallback = new MediaControllerCompat.Callback() { +// @Override +// public void onMetadataChanged(MediaMetadataCompat metadata) { +// Map map = new HashMap<>(); +// map.put("mediaItem", mediaMetadata2raw(metadata)); +// invokeClientMethod("onMediaItemChanged", map); +// } +// +// @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; +// Map stateMap = new HashMap<>(); +// stateMap.put("processingState", AudioService.instance.getProcessingState().ordinal()); +// stateMap.put("playing", AudioService.instance.isPlaying()); +// stateMap.put("controls", new ArrayList<>()); +// long actionBits = state.getActions(); +// List systemActions = new ArrayList<>(); +// for (int actionIndex = 0; actionIndex < 64; actionIndex++) { +// if ((actionBits & (1 << actionIndex)) != 0) { +// systemActions.add(actionIndex); +// } +// } +// stateMap.put("systemActions", systemActions); +// stateMap.put("updatePosition", state.getPosition()); +// stateMap.put("bufferedPosition", state.getBufferedPosition()); +// stateMap.put("speed", state.getPlaybackSpeed()); +// stateMap.put("updateTime", updateTimeSinceEpoch); +// stateMap.put("repeatMode", AudioService.instance.getRepeatMode()); +// stateMap.put("shuffleMode", AudioService.instance.getShuffleMode()); +// Map map = new HashMap<>(); +// map.put("state", stateMap); +// invokeClientMethod("onPlaybackStateChanged", map); +// } +// +// @Override +// public void onQueueChanged(List queue) { +// Map map = new HashMap<>(); +// map.put("queue", queue2raw(queue)); +// invokeClientMethod("onQueueChanged", map); +// } + }; +// private static void invokeClientMethod(String method, Object arg) { +// for (ClientInterface clientInterface : clientInterfaces) { +// clientInterface.channel.invokeMethod(method, arg); +// } +// } + + // + // INSTANCE FIELDS AND METHODS + // + + private Context applicationContext; + private FlutterPluginBinding flutterPluginBinding; + private ActivityPluginBinding activityPluginBinding; + private NewIntentListener newIntentListener; + private ClientInterface clientInterface; + private final MediaBrowserCompat.ConnectionCallback connectionCallback = new MediaBrowserCompat.ConnectionCallback() { + @Override + public void onConnected() { + if (applicationContext == null) return; + try { + MediaSessionCompat.Token token = mediaBrowser.getSessionToken(); + mediaController = new MediaControllerCompat(applicationContext, token); + Activity activity = mainClientInterface != null ? mainClientInterface.activity : null; + 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); + if (configureResult != null) { + configureResult.success(mapOf()); + configureResult = null; + } + } catch (Exception e) { + System.out.println("onConnected error: " + e.getMessage()); + e.printStackTrace(); + if (configureResult != null) { + configureResult.error("onConnected error: " + e.getMessage(), null, null); + } else { + clientInterface.setServiceConnectionFailed(true); + } + } + } + + @Override + public void onConnectionSuspended() { + // TODO: Handle this + System.out.println("### UNHANDLED: onConnectionSuspended"); + } + + @Override + public void onConnectionFailed() { + if (configureResult != null) { + configureResult.error("Unable to bind to AudioService. Please ensure you have declared a element as described in the README.", null, null); + } else { + clientInterface.setServiceConnectionFailed(true); + } + } + }; + + + // + // FlutterPlugin callbacks + // + + @Override + public void onAttachedToEngine(FlutterPluginBinding binding) { + flutterPluginBinding = binding; + clientInterface = new ClientInterface(flutterPluginBinding.getBinaryMessenger()); + clientInterface.setContext(flutterPluginBinding.getApplicationContext()); + clientInterfaces.add(clientInterface); + if (applicationContext == null) { + applicationContext = flutterPluginBinding.getApplicationContext(); + } + if (audioHandlerInterface == null) { + // We don't know yet whether this is the right engine that hosts the AudioHandler, + // but we need to register a MethodCallHandler now just in case. If we're wrong, we + // detect and correct this when receiving the "configure" message. + audioHandlerInterface = new AudioHandlerInterface(flutterPluginBinding.getBinaryMessenger()); + AudioService.init(audioHandlerInterface); + } + if (mediaBrowser == null) { + connect(); + } + } + + @Override + public void onDetachedFromEngine(FlutterPluginBinding binding) { + if (clientInterfaces.size() == 1) { + disconnect(); + } + clientInterfaces.remove(clientInterface); + clientInterface.setContext(null); + clientInterface = null; + applicationContext = null; + if (audioHandlerInterface != null + && audioHandlerInterface.messenger == flutterPluginBinding.getBinaryMessenger()) { + System.out.println("### destroying audio handler interface"); + audioHandlerInterface.destroy(); + audioHandlerInterface = null; + } + flutterPluginBinding = null; + } + + // + // ActivityAware callbacks + // + + @Override + public void onAttachedToActivity(ActivityPluginBinding binding) { + activityPluginBinding = binding; + clientInterface.setActivity(binding.getActivity()); + clientInterface.setContext(binding.getActivity()); + // Verify that the app is configured with the correct FlutterEngine. + FlutterEngine sharedEngine = getFlutterEngine(binding.getActivity()); + clientInterface.setWrongEngineDetected(flutterPluginBinding.getBinaryMessenger() != sharedEngine.getDartExecutor()); + mainClientInterface = clientInterface; + registerOnNewIntentListener(); + if (mediaController != null) { + MediaControllerCompat.setMediaController(mainClientInterface.activity, mediaController); + } + if (mediaBrowser == null) { + connect(); + } + + Activity activity = mainClientInterface.activity; + if (clientInterface.wasLaunchedFromRecents()) { + // We do this to avoid using the old intent. + activity.setIntent(new Intent(Intent.ACTION_MAIN)); + } + sendNotificationClicked(); + } + + @Override + public void onDetachedFromActivityForConfigChanges() { + activityPluginBinding.removeOnNewIntentListener(newIntentListener); + activityPluginBinding = null; + clientInterface.setActivity(null); + clientInterface.setContext(flutterPluginBinding.getApplicationContext()); + } + + @Override + public void onReattachedToActivityForConfigChanges(ActivityPluginBinding binding) { + activityPluginBinding = binding; + clientInterface.setActivity(binding.getActivity()); + clientInterface.setContext(binding.getActivity()); + registerOnNewIntentListener(); + } + + @Override + public void onDetachedFromActivity() { + activityPluginBinding.removeOnNewIntentListener(newIntentListener); + activityPluginBinding = null; + newIntentListener = null; + clientInterface.setActivity(null); + clientInterface.setContext(flutterPluginBinding.getApplicationContext()); + if (clientInterfaces.size() == 1) { + // This unbinds from the service allowing AudioService.onDestroy to + // happen which in turn allows the FlutterEngine to be destroyed. + disconnect(); + } + if (clientInterface == mainClientInterface) { + mainClientInterface = null; + } + } + + private void connect() { + if (mediaBrowser == null) { + mediaBrowser = new MediaBrowserCompat(applicationContext, + new ComponentName(applicationContext, AudioService.class), + connectionCallback, + null); + mediaBrowser.connect(); + } + } + + private void disconnect() { + Activity activity = mainClientInterface != null ? mainClientInterface.activity : null; + if (activity != null) { + // 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 (mediaBrowser != null) { + mediaBrowser.disconnect(); + mediaBrowser = null; + } + } + + private void registerOnNewIntentListener() { + activityPluginBinding.addOnNewIntentListener(newIntentListener = (intent) -> { + clientInterface.activity.setIntent(intent); + sendNotificationClicked(); + return true; + }); + } + + private void sendNotificationClicked() { + Activity activity = clientInterface.activity; + if (audioHandlerInterface != null && activity.getIntent().getAction() != null) { + boolean clicked = activity.getIntent().getAction().equals(AudioService.NOTIFICATION_CLICK_ACTION); + audioHandlerInterface.invokeMethod("onNotificationClicked", mapOf("clicked", clicked)); + } + } + + private static class ClientInterface implements MethodCallHandler { + private Context context; + private Activity activity; + public final BinaryMessenger messenger; + private final MethodChannel channel; + private boolean wrongEngineDetected; + private boolean serviceConnectionFailed; + + // This is implemented in Dart already. + // But we may need to bring this back if we want to connect to another process's media session. +// private final MediaBrowserCompat.SubscriptionCallback subscriptionCallback = new MediaBrowserCompat.SubscriptionCallback() { +// @Override +// public void onChildrenLoaded(@NonNull String parentId, @NonNull List children) { +// Map map = new HashMap(); +// map.put("parentMediaId", parentId); +// map.put("children", mediaItems2raw(children)); +// invokeClientMethod("onChildrenLoaded", map); +// } +// }; + + public ClientInterface(BinaryMessenger messenger) { + this.messenger = messenger; + channel = new MethodChannel(messenger, CHANNEL_CLIENT); + channel.setMethodCallHandler(this); + } + + private void setContext(Context context) { + this.context = context; + } + + private void setActivity(Activity activity) { + this.activity = activity; + } + + public void setWrongEngineDetected(boolean value) { + wrongEngineDetected = value; + } + + public void setServiceConnectionFailed(boolean value) { + serviceConnectionFailed = value; + } + + // 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 { + if (wrongEngineDetected) { + throw new IllegalStateException("The Activity class declared in your AndroidManifest.xml is wrong or has not provided the correct FlutterEngine. Please see the README for instructions."); + } + switch (call.method) { + case "configure": + if (serviceConnectionFailed) { + throw new IllegalStateException("Unable to bind to AudioService. Please ensure you have declared a element as described in the README."); + } + flutterReady = true; + Map args = (Map)call.arguments; + Map configMap = (Map)args.get("config"); + AudioServiceConfig config = new AudioServiceConfig(context.getApplicationContext()); + config.androidNotificationClickStartsActivity = (Boolean)configMap.get("androidNotificationClickStartsActivity"); + config.androidNotificationOngoing = (Boolean)configMap.get("androidNotificationOngoing"); + config.androidResumeOnClick = (Boolean)configMap.get("androidResumeOnClick"); + config.androidNotificationChannelId = (String)configMap.get("androidNotificationChannelId"); + config.androidNotificationChannelName = (String)configMap.get("androidNotificationChannelName"); + config.androidNotificationChannelDescription = (String)configMap.get("androidNotificationChannelDescription"); + config.notificationColor = configMap.get("notificationColor") == null ? -1 : getInt(configMap.get("notificationColor")); + config.androidNotificationIcon = (String)configMap.get("androidNotificationIcon"); + config.androidShowNotificationBadge = (Boolean)configMap.get("androidShowNotificationBadge"); + config.androidStopForegroundOnPause = (Boolean)configMap.get("androidStopForegroundOnPause"); + config.artDownscaleWidth = configMap.get("artDownscaleWidth") != null ? (Integer)configMap.get("artDownscaleWidth") : -1; + config.artDownscaleHeight = configMap.get("artDownscaleHeight") != null ? (Integer)configMap.get("artDownscaleHeight") : -1; + config.setBrowsableRootExtras((Map)configMap.get("androidBrowsableRootExtras")); + if (activity != null) { + config.activityClassName = activity.getClass().getName(); + } + config.save(); + if (AudioService.instance != null) { + AudioService.instance.configure(config); + } + mainClientInterface = ClientInterface.this; + if (audioHandlerInterface == null) { + audioHandlerInterface = new AudioHandlerInterface(messenger); + AudioService.init(audioHandlerInterface); + } else { + if (audioHandlerInterface.messenger != messenger) { + // We've detected this is the real engine hosting the AudioHandler, + // so update AudioHandlerInterface to connect to it. + audioHandlerInterface.switchToMessenger(messenger); + } + audioHandlerInterface.invokePendingMethods(); + } + if (mediaController != null) { + result.success(mapOf()); + } else { + configureResult = result; + } + break; + } + } catch (Exception e) { + e.printStackTrace(); + result.error(e.getMessage(), null, null); + } + } + } + + private static class AudioHandlerInterface implements MethodCallHandler, AudioService.ServiceListener { + private static final int SILENCE_SAMPLE_RATE = 44100; + public BinaryMessenger messenger; + public MethodChannel channel; + private AudioTrack silenceAudioTrack; + private final Handler handler = new Handler(Looper.getMainLooper()); + private List methodInvocationQueue = new LinkedList(); + + public AudioHandlerInterface(BinaryMessenger messenger) { + this.messenger = messenger; + channel = new MethodChannel(messenger, CHANNEL_HANDLER); + channel.setMethodCallHandler(this); + } + + public void switchToMessenger(BinaryMessenger messenger) { + channel.setMethodCallHandler(null); + this.messenger = messenger; + channel = new MethodChannel(messenger, CHANNEL_HANDLER); + channel.setMethodCallHandler(this); + } + + public void invokePendingMethods() { + for (MethodInvocation mi : methodInvocationQueue) { + channel.invokeMethod(mi.method, mi.arg, mi.result); + } + methodInvocationQueue.clear(); + } + + @Override + public void onLoadChildren(final String parentMediaId, final MediaBrowserServiceCompat.Result> result, Bundle options) { + if (audioHandlerInterface != null) { + Map args = new HashMap<>(); + args.put("parentMediaId", parentMediaId); + args.put("options", bundleToMap(options)); + audioHandlerInterface.invokeMethod("getChildren", args, 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) { + Map response = (Map)obj; + @SuppressWarnings("unchecked") List> rawMediaItems = (List>)response.get("children"); + List mediaItems = new ArrayList<>(); + for (Map rawMediaItem : rawMediaItems) { + mediaItems.add(rawToMediaItem(rawMediaItem)); + } + result.sendResult(mediaItems); + } + }); + } + result.detach(); + } + + @Override + public void onLoadItem(String itemId, final MediaBrowserServiceCompat.Result result) { + if (audioHandlerInterface != null) { + Map args = new HashMap<>(); + args.put("mediaId", itemId); + + audioHandlerInterface.invokeMethod("getMediaItem", args, 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) { + Map response = (Map)obj; + Map rawMediaItem = (Map)response.get("mediaItem"); + if (rawMediaItem != null) { + MediaBrowserCompat.MediaItem mediaItem = rawToMediaItem(rawMediaItem); + result.sendResult(mediaItem); + } else { + result.sendResult(null); + } + } + }); + } + result.detach(); + } + + @Override + public void onSearch(String query, Bundle extras, final MediaBrowserServiceCompat.Result> result) { + if (audioHandlerInterface != null) { + Map args = new HashMap<>(); + args.put("query", query); + args.put("extras", bundleToMap(extras)); + audioHandlerInterface.invokeMethod("search", args, 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) { + Map response = (Map)obj; + @SuppressWarnings("unchecked") List> rawMediaItems = (List>)response.get("mediaItems"); + List mediaItems = new ArrayList<>(); + for (Map rawMediaItem : rawMediaItems) { + mediaItems.add(rawToMediaItem(rawMediaItem)); + } + result.sendResult(mediaItems); + } + }); + } + result.detach(); + } + + @Override + public void onClick(MediaButton mediaButton) { + invokeMethod("click", mapOf("button", mediaButton.ordinal())); + } + + @Override + public void onPause() { + invokeMethod("pause", mapOf()); + } + + @Override + public void onPrepare() { + invokeMethod("prepare", mapOf()); + } + + @Override + public void onPrepareFromMediaId(String mediaId, Bundle extras) { + invokeMethod("prepareFromMediaId", mapOf( + "mediaId", mediaId, + "extras", bundleToMap(extras))); + } + + @Override + public void onPrepareFromSearch(String query, Bundle extras) { + invokeMethod("prepareFromSearch", mapOf( + "query", query, + "extras", bundleToMap(extras))); + } + + @Override + public void onPrepareFromUri(Uri uri, Bundle extras) { + invokeMethod("prepareFromUri", mapOf( + "uri", uri.toString(), + "extras", bundleToMap(extras))); + } + + @Override + public void onPlay() { + invokeMethod("play", mapOf()); + } + + @Override + public void onPlayFromMediaId(String mediaId, Bundle extras) { + invokeMethod("playFromMediaId", mapOf( + "mediaId", mediaId, + "extras", bundleToMap(extras))); + } + + @Override + public void onPlayFromSearch(String query, Bundle extras) { + invokeMethod("playFromSearch", mapOf( + "query", query, + "extras", bundleToMap(extras))); + } + + @Override + public void onPlayFromUri(Uri uri, Bundle extras) { + invokeMethod("playFromUri", mapOf( + "uri", uri.toString(), + "extras", bundleToMap(extras))); + } + + @Override + public void onPlayMediaItem(MediaMetadataCompat metadata) { + invokeMethod("playMediaItem", mapOf("mediaItem", mediaMetadata2raw(metadata))); + } + + @Override + public void onStop() { + invokeMethod("stop", mapOf()); + } + + @Override + public void onAddQueueItem(MediaMetadataCompat metadata) { + invokeMethod("addQueueItem", mapOf("mediaItem", mediaMetadata2raw(metadata))); + } + + @Override + public void onAddQueueItemAt(MediaMetadataCompat metadata, int index) { + invokeMethod("insertQueueItem", mapOf( + "mediaItem", mediaMetadata2raw(metadata), + "index", index)); + } + + @Override + public void onRemoveQueueItem(MediaMetadataCompat metadata) { + invokeMethod("removeQueueItem", mapOf("mediaItem", mediaMetadata2raw(metadata))); + } + + @Override + public void onRemoveQueueItemAt(int index) { + invokeMethod("removeQueueItemAt", mapOf("index", index)); + } + + @Override + public void onSkipToQueueItem(long queueItemId) { + invokeMethod("skipToQueueItem", mapOf("index", queueItemId)); + } + + @Override + public void onSkipToNext() { + invokeMethod("skipToNext", mapOf()); + } + + @Override + public void onSkipToPrevious() { + invokeMethod("skipToPrevious", mapOf()); + } + + @Override + public void onFastForward() { + invokeMethod("fastForward", mapOf()); + } + + @Override + public void onRewind() { + invokeMethod("rewind", mapOf()); + } + + @Override + public void onSeekTo(long pos) { + invokeMethod("seek", mapOf("position", pos*1000)); + } + + @Override + public void onSetPlaybackSpeed(float speed) { + invokeMethod("setSpeed", mapOf("speed", speed)); + } + + @Override + public void onSetCaptioningEnabled(boolean enabled) { + invokeMethod("setCaptioningEnabled", mapOf("enabled", enabled)); + } + + @Override + public void onSetRepeatMode(int repeatMode) { + invokeMethod("setRepeatMode", mapOf("repeatMode", repeatMode)); + } + + @Override + public void onSetShuffleMode(int shuffleMode) { + invokeMethod("setShuffleMode", mapOf("shuffleMode", shuffleMode)); + } + + @Override + public void onCustomAction(String action, Bundle extras) { + invokeMethod("customAction", mapOf( + "name", action, + "extras", bundleToMap(extras))); + } + + @Override + public void onSetRating(RatingCompat rating) { + invokeMethod("setRating", mapOf( + "rating", rating2raw(rating), + "extras", null)); + } + + @Override + public void onSetRating(RatingCompat rating, Bundle extras) { + invokeMethod("setRating", mapOf( + "rating", rating2raw(rating), + "extras", bundleToMap(extras))); + } + + @Override + public void onSetVolumeTo(int volumeIndex) { + invokeMethod("androidSetRemoteVolume", mapOf("volumeIndex", volumeIndex)); + } + + @Override + public void onAdjustVolume(int direction) { + invokeMethod("androidAdjustRemoteVolume", mapOf("direction", direction)); + } + + @Override + public void onTaskRemoved() { + invokeMethod("onTaskRemoved", mapOf()); + } + + @Override + public void onClose() { + invokeMethod("onNotificationDeleted", mapOf()); + } + + @Override + public void onDestroy() { + disposeFlutterEngine(); + } + + @Override + public void onMethodCall(MethodCall call, Result result) { + try { + Map args = (Map)call.arguments; + switch (call.method) { + case "setMediaItem": { + Executors.newSingleThreadExecutor().execute(() -> { + try { + Map rawMediaItem = (Map)args.get("mediaItem"); + MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem); + AudioService.instance.setMetadata(mediaMetadata); + handler.post(() -> result.success(null)); + } catch (Exception e) { + handler.post(() -> { + result.error("UNEXPECTED_ERROR", "Unexpected error", Log.getStackTraceString(e)); + }); + } + }); + break; + } + case "setQueue": { + Executors.newSingleThreadExecutor().execute(() -> { + try { + @SuppressWarnings("unchecked") List> rawQueue = (List>) args.get("queue"); + List queue = raw2queue(rawQueue); + AudioService.instance.setQueue(queue); + handler.post(() -> result.success(null)); + } catch (Exception e) { + handler.post(() -> { + result.error("UNEXPECTED_ERROR", "Unexpected error", Log.getStackTraceString(e)); + }); + } + }); + break; + } + case "setState": { + Map stateMap = (Map)args.get("state"); + AudioProcessingState processingState = AudioProcessingState.values()[(Integer)stateMap.get("processingState")]; + boolean playing = (Boolean)stateMap.get("playing"); + @SuppressWarnings("unchecked") List> rawControls = (List>)stateMap.get("controls"); + @SuppressWarnings("unchecked") List compactActionIndexList = (List)stateMap.get("androidCompactActionIndices"); + @SuppressWarnings("unchecked") List rawSystemActions = (List)stateMap.get("systemActions"); + long position = getLong(stateMap.get("updatePosition")); + long bufferedPosition = getLong(stateMap.get("bufferedPosition")); + float speed = (float)((double)((Double)stateMap.get("speed"))); + long updateTimeSinceEpoch = stateMap.get("updateTime") == null ? System.currentTimeMillis() : getLong(stateMap.get("updateTime")); + Integer errorCode = (Integer)stateMap.get("errorCode"); + String errorMessage = (String)stateMap.get("errorMessage"); + int repeatMode = (Integer)stateMap.get("repeatMode"); + int shuffleMode = (Integer)stateMap.get("shuffleMode"); + Long queueIndex = getLong(stateMap.get("queueIndex")); + boolean captioningEnabled = (Boolean)stateMap.get("captioningEnabled"); + + // 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<>(); + long actionBits = 0; + for (Map rawControl : rawControls) { + String resource = (String)rawControl.get("androidIcon"); + String label = (String)rawControl.get("label"); + long actionCode = 1 << ((Integer)rawControl.get("action")); + actionBits |= actionCode; + Map customActionMap = (Map)rawControl.get("customAction"); + CustomMediaAction customAction = null; + if (customActionMap != null) { + String name = (String) customActionMap.get("name"); + Map extras = (Map) customActionMap.get("extras"); + customAction = new CustomMediaAction(name, extras); + } + actions.add(new MediaControl(resource, label, actionCode, customAction)); + } + for (Integer rawSystemAction : rawSystemActions) { + long 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, + errorCode, + errorMessage, + repeatMode, + shuffleMode, + captioningEnabled, + queueIndex); + result.success(null); + break; + } + case "setAndroidPlaybackInfo": { + Map playbackInfo = (Map)args.get("playbackInfo"); + final int playbackType = (Integer)playbackInfo.get("playbackType"); + final Integer volumeControlType = (Integer)playbackInfo.get("volumeControlType"); + final Integer maxVolume = (Integer)playbackInfo.get("maxVolume"); + final Integer volume = (Integer)playbackInfo.get("volume"); + AudioService.instance.setPlaybackInfo(playbackType, volumeControlType, maxVolume, volume); + result.success(null); + break; + } + case "notifyChildrenChanged": { + String parentMediaId = (String)args.get("parentMediaId"); + Map options = (Map)args.get("options"); + AudioService.instance.notifyChildrenChanged(parentMediaId, mapToBundle(options)); + result.success(null); + 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) { + byte[] silence = new byte[2048]; + // TODO: Uncomment this after moving to a minSdkVersion of 21. + /* AudioAttributes audioAttributes = new AudioAttributes.Builder() */ + /* .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) */ + /* .setUsage(AudioAttributes.USAGE_MEDIA) */ + /* .build(); */ + /* AudioFormat audioFormat = new AudioFormat.Builder() */ + /* .setChannelMask(AudioFormat.CHANNEL_CONFIGURATION_MONO) */ + /* .setEncoding(AudioFormat.ENCODING_PCM_8BIT) */ + /* .setSampleRate(SILENCE_SAMPLE_RATE) */ + /* .build(); */ + /* silenceAudioTrack = new AudioTrack.Builder() */ + /* .setAudioAttributes(audioAttributes) */ + /* .setAudioFormat(audioFormat) */ + /* .setBufferSizeInBytes(silence.length) */ + /* .setTransferMode(AudioTrack.MODE_STATIC) */ + /* .build(); */ + @SuppressWarnings("deprecation") + final AudioTrack audioTrack = new AudioTrack( + AudioManager.STREAM_MUSIC, + SILENCE_SAMPLE_RATE, + AudioFormat.CHANNEL_CONFIGURATION_MONO, + AudioFormat.ENCODING_PCM_8BIT, + silence.length, + AudioTrack.MODE_STATIC); + silenceAudioTrack = audioTrack; + silenceAudioTrack.write(silence, 0, silence.length); + } + silenceAudioTrack.reloadStaticData(); + silenceAudioTrack.play(); + result.success(null); + break; + } + case "stopService": { + if (AudioService.instance != null) { + AudioService.instance.stop(); + } + result.success(null); + break; + } + } + } catch (Exception e) { + e.printStackTrace(); + result.error(e.getMessage(), null, null); + } + } + + @UiThread + public void invokeMethod(String method, Object arg) { + invokeMethod(method, arg, null); + } + + @UiThread + public void invokeMethod(String method, Object arg, final Result result) { + if (flutterReady) { + channel.invokeMethod(method, arg, result); + } else { + methodInvocationQueue.add(new MethodInvocation(method, arg, result)); + } + } + + private void destroy() { + if (silenceAudioTrack != null) + silenceAudioTrack.release(); + } + } + + 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(((Double)value).floatValue()); + 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) + 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("title", metadataToString(mediaMetadata, MediaMetadataCompat.METADATA_KEY_TITLE)); + raw.put("album", metadataToString(mediaMetadata, MediaMetadataCompat.METADATA_KEY_ALBUM)); + 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("playable", mediaMetadata.getLong("playable_long") != 0); + 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 = bundleToMap(mediaMetadata.getBundle()); + if (extras.size() > 0) { + raw.put("extras", extras); + } + return raw; + } + + private static MediaMetadataCompat createMediaMetadata(Map rawMediaItem) { + //noinspection unchecked + return AudioService.instance.createMediaMetadata( + (String)rawMediaItem.get("id"), + (String)rawMediaItem.get("title"), + (String)rawMediaItem.get("album"), + (String)rawMediaItem.get("artist"), + (String)rawMediaItem.get("genre"), + getLong(rawMediaItem.get("duration")), + (String)rawMediaItem.get("artUri"), + (Boolean)rawMediaItem.get("playable"), + (String)rawMediaItem.get("displayTitle"), + (String)rawMediaItem.get("displaySubtitle"), + (String)rawMediaItem.get("displayDescription"), + raw2rating((Map)rawMediaItem.get("rating")), + (Map)rawMediaItem.get("extras") + ); + } + + /** + * Propagate mediaItem extras passed from dart to the description. By default, when creating + * a MediaMetadataCompat object, it doesn't propagate all the extras to the MediaDescription + * instance it holds. + * + * @param description original description object + * @param extras extras map coming from dart + * @return description with added extras + */ + private static MediaDescriptionCompat addExtrasToMediaDescription(MediaDescriptionCompat description, Map extras) { + if (extras == null || extras.isEmpty()) { + return description; + } + final Bundle extrasBundle = new Bundle(); + if (description.getExtras() != null) { + extrasBundle.putAll(description.getExtras()); + } + extrasBundle.putAll(mapToBundle(extras)); + return new MediaDescriptionCompat.Builder() + .setTitle(description.getTitle()) + .setSubtitle(description.getSubtitle()) + .setDescription(description.getDescription()) + .setIconBitmap(description.getIconBitmap()) + .setIconUri(description.getIconUri()) + .setMediaId(description.getMediaId()) + .setMediaUri(description.getMediaUri()) + .setExtras(extrasBundle).build(); + } + + private static MediaBrowserCompat.MediaItem rawToMediaItem(Map rawMediaItem) { + MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem); + final MediaDescriptionCompat description = addExtrasToMediaDescription(mediaMetadata.getDescription(), (Map)rawMediaItem.get("extras")); + final Boolean playable = (Boolean)rawMediaItem.get("playable"); + return new MediaBrowserCompat.MediaItem(description, playable ? MediaBrowserCompat.MediaItem.FLAG_PLAYABLE : MediaBrowserCompat.MediaItem.FLAG_BROWSABLE); + } + + private static List raw2queue(List> rawQueue) { + List queue = new ArrayList<>(); + int i = 0; + for (Map rawMediaItem : rawQueue) { + MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem); + MediaDescriptionCompat description = addExtrasToMediaDescription(mediaMetadata.getDescription(), (Map)rawMediaItem.get("extras")); + queue.add(new MediaSessionCompat.QueueItem(description, i)); + i++; + } + return queue; + } + + public static Long getLong(Object o) { + return (o == null || o instanceof Long) ? (Long)o : Long.valueOf((Integer) o); + } + + public static Integer getInt(Object o) { + return (o == null || o instanceof Integer) ? (Integer)o : Integer.valueOf((int)((Long)o).longValue()); + } + + static Map bundleToMap(Bundle bundle) { + if (bundle == null) return null; + Map map = new HashMap<>(); + for (String key : bundle.keySet()) { + // TODO: use typesafe version once SDK 33 is released. + @SuppressWarnings("deprecation") + Object value = bundle.getSerializable(key); + if (value != null) { + map.put(key, value); + } + } + return map; + } + + static Bundle mapToBundle(Map map) { + if (map == null) return null; + final Bundle bundle = new Bundle(); + for (Object key : map.keySet()) { + String skey = (String)key; + Object value = map.get(skey); + if (value instanceof Integer) bundle.putInt(skey, (Integer)value); + else if (value instanceof Long) bundle.putLong(skey, (Long)value); + else if (value instanceof Double) bundle.putDouble(skey, (Double)value); + else if (value instanceof Boolean) bundle.putBoolean(skey, (Boolean)value); + else if (value instanceof String) bundle.putString(skey, (String)value); + } + return bundle; + } + + static Map mapOf(Object... args) { + Map map = new HashMap<>(); + for (int i = 0; i < args.length; i += 2) { + map.put((String)args[i], args[i + 1]); + } + return map; + } + + static class MethodInvocation { + public final String method; + public final Object arg; + public final Result result; + + public MethodInvocation(String method, Object arg, Result result) { + this.method = method; + this.arg = arg; + this.result = result; + } + } +} diff --git a/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/CustomMediaAction.java b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/CustomMediaAction.java new file mode 100644 index 00000000..da942aa7 --- /dev/null +++ b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/CustomMediaAction.java @@ -0,0 +1,27 @@ +package com.ryanheise.audioservice; + +import java.util.Map; +import java.util.Objects; + +public class CustomMediaAction { + public final String name; + public final Map extras; + + public CustomMediaAction(String name, Map extras) { + this.name = name; + this.extras = extras; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CustomMediaAction that = (CustomMediaAction) o; + return name.equals(that.name) && Objects.equals(extras, that.extras); + } + + @Override + public int hashCode() { + return Objects.hash(name, extras); + } +} diff --git a/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/MediaButton.java b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/MediaButton.java new file mode 100644 index 00000000..89d937f5 --- /dev/null +++ b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/MediaButton.java @@ -0,0 +1,7 @@ +package com.ryanheise.audioservice; + +public enum MediaButton { + media, + next, + previous +} diff --git a/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/MediaButtonReceiver.java b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/MediaButtonReceiver.java new file mode 100644 index 00000000..0eb47890 --- /dev/null +++ b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/MediaButtonReceiver.java @@ -0,0 +1,25 @@ +package com.ryanheise.audioservice; + +import android.content.Context; +import android.content.Intent; + +public class MediaButtonReceiver extends androidx.media.session.MediaButtonReceiver { + public static final String ACTION_CUSTOM = "com.ryanheise.audioservice.intent.action.CUSTOM"; + public static final String ACTION_NOTIFICATION_DELETE = "com.ryanheise.audioservice.intent.action.ACTION_NOTIFICATION_DELETE"; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent != null && ACTION_CUSTOM.equals(intent.getAction()) && AudioService.instance != null) { + AudioService.instance.handleCustomNotificationAction( + intent.getStringExtra("action"), intent.getBundleExtra("extras")); + return; + } + if (intent != null + && ACTION_NOTIFICATION_DELETE.equals(intent.getAction()) + && AudioService.instance != null) { + AudioService.instance.handleDeleteNotification(); + return; + } + super.onReceive(context, intent); + } +} diff --git a/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/MediaControl.java b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/MediaControl.java new file mode 100644 index 00000000..d78f7e99 --- /dev/null +++ b/third_party/audio_service/android/src/main/java/com/ryanheise/audioservice/MediaControl.java @@ -0,0 +1,27 @@ +package com.ryanheise.audioservice; + +import java.util.Objects; + +public class MediaControl { + public final String icon; + public final String label; + public final long actionCode; + public final CustomMediaAction customAction; + + public MediaControl(String icon, String label, long actionCode, CustomMediaAction customAction) { + this.icon = icon; + this.label = label; + this.actionCode = actionCode; + this.customAction = customAction; + } + + @Override + public boolean equals(Object other) { + if (other instanceof MediaControl) { + MediaControl otherControl = (MediaControl)other; + return icon.equals(otherControl.icon) && label.equals(otherControl.label) && actionCode == otherControl.actionCode && Objects.equals(customAction, otherControl.customAction); + } else { + return false; + } + } +} diff --git a/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_fast_forward.png b/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_fast_forward.png new file mode 100644 index 00000000..24d97839 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_fast_forward.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_fast_rewind.png b/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_fast_rewind.png new file mode 100644 index 00000000..3b564bab Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_fast_rewind.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_pause.png b/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_pause.png new file mode 100644 index 00000000..794f350d Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_pause.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_play_arrow.png b/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_play_arrow.png new file mode 100644 index 00000000..84998dd6 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_play_arrow.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_skip_next.png b/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_skip_next.png new file mode 100644 index 00000000..100237c5 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_skip_next.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_skip_previous.png b/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_skip_previous.png new file mode 100644 index 00000000..7dd69775 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_skip_previous.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_stop.png b/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_stop.png new file mode 100644 index 00000000..7494d030 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-hdpi/audio_service_stop.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_fast_forward.png b/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_fast_forward.png new file mode 100644 index 00000000..f81de6fa Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_fast_forward.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_fast_rewind.png b/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_fast_rewind.png new file mode 100644 index 00000000..7d1797e5 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_fast_rewind.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_pause.png b/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_pause.png new file mode 100644 index 00000000..e2f63777 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_pause.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_play_arrow.png b/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_play_arrow.png new file mode 100644 index 00000000..084d6f31 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_play_arrow.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_skip_next.png b/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_skip_next.png new file mode 100644 index 00000000..13663f15 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_skip_next.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_skip_previous.png b/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_skip_previous.png new file mode 100644 index 00000000..bca4c3a9 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_skip_previous.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_stop.png b/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_stop.png new file mode 100644 index 00000000..413184ae Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-mdpi/audio_service_stop.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_fast_forward.png b/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_fast_forward.png new file mode 100644 index 00000000..ceec9797 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_fast_forward.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_fast_rewind.png b/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_fast_rewind.png new file mode 100644 index 00000000..52c5e7c3 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_fast_rewind.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_pause.png b/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_pause.png new file mode 100644 index 00000000..04759eed Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_pause.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_play_arrow.png b/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_play_arrow.png new file mode 100644 index 00000000..7b9b221e Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_play_arrow.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_skip_next.png b/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_skip_next.png new file mode 100644 index 00000000..f8662731 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_skip_next.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_skip_previous.png b/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_skip_previous.png new file mode 100644 index 00000000..f5a741e9 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_skip_previous.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_stop.png b/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_stop.png new file mode 100644 index 00000000..f7d0bc90 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xhdpi/audio_service_stop.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_fast_forward.png b/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_fast_forward.png new file mode 100644 index 00000000..058b149c Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_fast_forward.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_fast_rewind.png b/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_fast_rewind.png new file mode 100644 index 00000000..3f2bde87 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_fast_rewind.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_pause.png b/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_pause.png new file mode 100644 index 00000000..4a58a391 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_pause.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_play_arrow.png b/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_play_arrow.png new file mode 100644 index 00000000..4c3fc474 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_play_arrow.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_skip_next.png b/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_skip_next.png new file mode 100644 index 00000000..ff679573 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_skip_next.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_skip_previous.png b/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_skip_previous.png new file mode 100644 index 00000000..c4d858e6 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_skip_previous.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_stop.png b/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_stop.png new file mode 100644 index 00000000..7f8775ae Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xxhdpi/audio_service_stop.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_fast_forward.png b/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_fast_forward.png new file mode 100644 index 00000000..6153646a Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_fast_forward.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_fast_rewind.png b/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_fast_rewind.png new file mode 100644 index 00000000..5de2f31e Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_fast_rewind.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_pause.png b/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_pause.png new file mode 100644 index 00000000..1d0ff1b0 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_pause.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_play_arrow.png b/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_play_arrow.png new file mode 100644 index 00000000..6fedb6fa Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_play_arrow.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_skip_next.png b/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_skip_next.png new file mode 100644 index 00000000..464a3cca Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_skip_next.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_skip_previous.png b/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_skip_previous.png new file mode 100644 index 00000000..fe0efaab Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_skip_previous.png differ diff --git a/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_stop.png b/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_stop.png new file mode 100644 index 00000000..580c6237 Binary files /dev/null and b/third_party/audio_service/android/src/main/res/drawable-xxxhdpi/audio_service_stop.png differ diff --git a/third_party/audio_service/darwin/audio_service.podspec b/third_party/audio_service/darwin/audio_service.podspec new file mode 100644 index 00000000..9a5ad987 --- /dev/null +++ b/third_party/audio_service/darwin/audio_service.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html +# +Pod::Spec.new do |s| + s.name = 'audio_service' + s.version = '0.0.1' + s.summary = 'Flutter plugin to play audio in the background while the screen is off.' + s.description = <<-DESC +Flutter plugin to play audio in the background while the screen is off. + DESC + s.homepage = 'https://github.com/ryanheise/audio_service' + s.license = { :file => '../LICENSE' } + s.author = { 'Ryan Heise' => 'ryan@ryanheise.com' } + s.source = { :path => '.' } + s.source_files = 'audio_service/Sources/audio_service/**/*.{h,m}' + s.public_header_files = 'audio_service/Sources/audio_service/include/**/*.h' + s.ios.dependency 'Flutter' + s.osx.dependency 'FlutterMacOS' + s.ios.deployment_target = '12.0' + s.osx.deployment_target = '10.14' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } +end + diff --git a/third_party/audio_service/darwin/audio_service/Package.swift b/third_party/audio_service/darwin/audio_service/Package.swift new file mode 100644 index 00000000..df367852 --- /dev/null +++ b/third_party/audio_service/darwin/audio_service/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "audio_service", + platforms: [ + .iOS("12.0"), + .macOS("10.14") + ], + products: [ + .library(name: "audio-service", targets: ["audio_service"]) + ], + dependencies: [], + targets: [ + .target( + name: "audio_service", + dependencies: [], + cSettings: [ + .headerSearchPath("include/audio_service") + ] + ) + ] +) diff --git a/third_party/audio_service/darwin/audio_service/Sources/audio_service/AudioServicePlugin.m b/third_party/audio_service/darwin/audio_service/Sources/audio_service/AudioServicePlugin.m new file mode 100644 index 00000000..4e39524c --- /dev/null +++ b/third_party/audio_service/darwin/audio_service/Sources/audio_service/AudioServicePlugin.m @@ -0,0 +1,577 @@ +#import "./include/audio_service/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! + +static NSHashTable *plugins = nil; +static FlutterMethodChannel *handlerChannel = nil; +static FlutterResult startResult = nil; +static MPRemoteCommandCenter *commandCenter = nil; +static NSMutableDictionary *mediaItem = nil; +static long actionBits = 0; +static NSMutableArray *commands; +static BOOL _controlsUpdated = NO; +static enum AudioProcessingState processingState = ApsIdle; +static BOOL playing = NO; +static NSNumber *position = nil; +static NSNumber *bufferedPosition = nil; +static NSNumber *updateTime = nil; +static NSNumber *speed = nil; +static NSNumber *repeatMode = nil; +static NSNumber *shuffleMode = nil; +static NSNumber *fastForwardInterval = nil; +static NSNumber *rewindInterval = nil; +static MPMediaItemArtwork* artwork = nil; +static NSMutableDictionary *nowPlayingInfo = nil; + +@implementation AudioServicePlugin { + FlutterMethodChannel *_channel; +} + ++ (void)registerWithRegistrar:(NSObject*)registrar { + @synchronized(self) { + if (!plugins) { + plugins = [NSHashTable weakObjectsHashTable]; + } + AudioServicePlugin *instance = [[AudioServicePlugin alloc] initWithRegistrar:registrar]; + [registrar addMethodCallDelegate:instance channel:instance.channel]; + [plugins addObject:instance]; + if (!handlerChannel) { + processingState = ApsIdle; + position = @(0); + bufferedPosition = @(0); + long long msSinceEpoch = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); + updateTime = [NSNumber numberWithLongLong: msSinceEpoch]; + speed = [NSNumber numberWithDouble: 1.0]; + repeatMode = @(0); + shuffleMode = @(0); + nowPlayingInfo = [NSMutableDictionary new]; + handlerChannel = [FlutterMethodChannel + methodChannelWithName:@"com.ryanheise.audio_service.handler.methods" + binaryMessenger:[registrar messenger]]; + [registrar addMethodCallDelegate:instance channel:handlerChannel]; + } + } +} + +- (instancetype)initWithRegistrar:(NSObject *)registrar { + self = [super init]; + NSAssert(self, @"super init cannot be nil"); + _channel = [FlutterMethodChannel + methodChannelWithName:@"com.ryanheise.audio_service.client.methods" + binaryMessenger:[registrar messenger]]; + return self; +} + +- (FlutterMethodChannel *)channel { + return _channel; +} + +- (void)invokeClientMethod:(NSString *)method arguments:(id _Nullable)arguments { + for (AudioServicePlugin *plugin in plugins) { + [plugin.channel invokeMethod:method arguments:arguments]; + } +} + +- (void)invokeClientMethod:(NSString *)method arguments:(id _Nullable)arguments result:(FlutterResult)result { + for (AudioServicePlugin *plugin in plugins) { + [plugin.channel invokeMethod:method arguments:arguments result:result]; + } +} + +- (void)activateCommandCenter { + //NSLog(@"### activateCommandCenter"); + commandCenter = [MPRemoteCommandCenter sharedCommandCenter]; + commands = [NSMutableArray new]; + [commands addObjectsFromArray:@[ + commandCenter.stopCommand, + commandCenter.pauseCommand, + commandCenter.playCommand, + commandCenter.skipBackwardCommand, + commandCenter.previousTrackCommand, + commandCenter.nextTrackCommand, + commandCenter.skipForwardCommand, + [NSNull null], + [NSNull null], // changePlaybackPositionCommand, put below + commandCenter.togglePlayPauseCommand, + [NSNull null], + [NSNull null], + [NSNull null], + [NSNull null], + [NSNull null], + [NSNull null], + [NSNull null], + [NSNull null], + commandCenter.changeRepeatModeCommand, + [NSNull null], + [NSNull null], + commandCenter.changeShuffleModeCommand, + commandCenter.seekBackwardCommand, + commandCenter.seekForwardCommand, + commandCenter.changePlaybackRateCommand, + ]]; + if (@available(iOS 9.1, macOS 10.12.2, *)) { + commands[8] = commandCenter.changePlaybackPositionCommand; + } + [commandCenter.changePlaybackRateCommand setEnabled:YES]; + [commandCenter.togglePlayPauseCommand setEnabled:YES]; + [commandCenter.togglePlayPauseCommand addTarget:self action:@selector(togglePlayPause:)]; + // TODO: enable more commands + // Language options + if (@available(iOS 9.0, macOS 10.12.2, *)) { + [commandCenter.enableLanguageOptionCommand setEnabled:NO]; + [commandCenter.disableLanguageOptionCommand setEnabled:NO]; + } + // Rating + [commandCenter.ratingCommand setEnabled:NO]; + // Feedback + [commandCenter.likeCommand setEnabled:NO]; + [commandCenter.dislikeCommand setEnabled:NO]; + [commandCenter.bookmarkCommand setEnabled:NO]; +} + +- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + if ([@"configure" isEqualToString:call.method]) { + NSDictionary *args = (NSDictionary *)call.arguments; + NSDictionary *configMap = (NSDictionary *)args[@"config"]; + fastForwardInterval = configMap[@"fastForwardInterval"]; + rewindInterval = configMap[@"rewindInterval"]; + result(@{}); + } else if ([@"setState" isEqualToString:call.method]) { + NSDictionary *args = (NSDictionary *)call.arguments; + NSDictionary *stateMap = (NSDictionary *)args[@"state"]; + long long msSinceEpoch; + if (stateMap[@"updateTime"] != [NSNull null]) { + msSinceEpoch = [stateMap[@"updateTime"] longLongValue]; + } else { + msSinceEpoch = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); + } + actionBits = 0; + NSArray *controlsArray = stateMap[@"controls"]; + for (int i = 0; i < controlsArray.count; i++) { + NSDictionary *control = (NSDictionary *)controlsArray[i]; + NSNumber *actionIndex = (NSNumber *)control[@"action"]; + int actionCode = 1 << [actionIndex intValue]; + actionBits |= actionCode; + } + NSArray *systemActionsArray = stateMap[@"systemActions"]; + for (int i = 0; i < systemActionsArray.count; i++) { + NSNumber *actionIndex = (NSNumber *)systemActionsArray[i]; + int actionCode = 1 << [actionIndex intValue]; + actionBits |= actionCode; + } + processingState = [stateMap[@"processingState"] intValue]; + BOOL oldPlaying = playing; + NSNumber *oldSpeed = speed; + NSNumber *oldPosition = position; + playing = [stateMap[@"playing"] boolValue]; + position = stateMap[@"updatePosition"]; + bufferedPosition = stateMap[@"bufferedPosition"]; + speed = stateMap[@"speed"]; + repeatMode = stateMap[@"repeatMode"]; + shuffleMode = stateMap[@"shuffleMode"]; + updateTime = [NSNumber numberWithLongLong: msSinceEpoch]; + if (playing && !commandCenter) { +#if TARGET_OS_IPHONE + [AVAudioSession sharedInstance]; +#endif + [self activateCommandCenter]; + } + [self updateControls]; + if (playing != oldPlaying || + speed.doubleValue != oldSpeed.doubleValue || + position.longLongValue != oldPosition.longLongValue) { + [self updateNowPlayingInfo]; + } + result(@{}); + } else if ([@"setQueue" isEqualToString:call.method]) { + result(@{}); + } else if ([@"setMediaItem" isEqualToString:call.method]) { + NSDictionary *args = (NSDictionary *)call.arguments; + mediaItem = args[@"mediaItem"]; + NSString* artUri = mediaItem[@"artUri"]; + artwork = nil; + if (![artUri isEqual: [NSNull null]]) { + NSString* artCacheFilePath = (NSString *)[NSNull null]; + NSDictionary* extras = mediaItem[@"extras"]; + if (![extras isEqual: [NSNull null]]) { + artCacheFilePath = extras[@"artCacheFile"]; + } + if (![artCacheFilePath isEqual: [NSNull null]]) { +#if TARGET_OS_IPHONE + UIImage* artImage = [UIImage imageWithContentsOfFile:artCacheFilePath]; +#else + NSImage* artImage = [[NSImage alloc] initWithContentsOfFile:artCacheFilePath]; +#endif + if (artImage != nil) { +#if TARGET_OS_IPHONE + artwork = [[MPMediaItemArtwork alloc] initWithImage: artImage]; +#else + artwork = [[MPMediaItemArtwork alloc] initWithBoundsSize:artImage.size requestHandler:^NSImage* _Nonnull(CGSize aSize) { + return artImage; + }]; +#endif + } + } + } + [self updateNowPlayingInfo]; + result(@{}); + } else if ([@"setPlaybackInfo" isEqualToString:call.method]) { + result(@{}); + } else if ([@"notifyChildrenChanged" isEqualToString:call.method]) { + result(@{}); + } else if ([@"androidForceEnableMediaButtons" isEqualToString:call.method]) { + result(@{}); + } else if ([@"stopService" isEqualToString:call.method]) { + [commandCenter.changePlaybackRateCommand setEnabled:NO]; + [commandCenter.togglePlayPauseCommand setEnabled:NO]; + [commandCenter.togglePlayPauseCommand removeTarget:nil]; + [MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = nil; + processingState = ApsIdle; + actionBits = 0; + [self updateControls]; + _controlsUpdated = NO; + startResult = nil; + commandCenter = nil; + result(@{}); + } +} + +- (MPRemoteCommandHandlerStatus) play: (MPRemoteCommandEvent *) event { + //NSLog(@"play"); + [handlerChannel invokeMethod:@"play" arguments:@{}]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) pause: (MPRemoteCommandEvent *) event { + //NSLog(@"pause"); + [handlerChannel invokeMethod:@"pause" arguments:@{}]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (BOOL)updateNowPlayingField:(NSString *)field value:(id)value { + if (![value isEqual:nowPlayingInfo[field]]) { + if (value != nil && value != [NSNull null]) { + //NSLog(@"### %@ = '%@'", field, value); + nowPlayingInfo[field] = value; + } else { + //NSLog(@"### %@ = nil", field); + [nowPlayingInfo removeObjectForKey:field]; + } + return YES; + } + return NO; +} + +- (void) updateNowPlayingInfo { + BOOL updated = NO; + if (mediaItem) { + updated |= [self updateNowPlayingField:MPMediaItemPropertyTitle value:mediaItem[@"title"]]; + updated |= [self updateNowPlayingField:MPMediaItemPropertyAlbumTitle value:mediaItem[@"album"]]; + updated |= [self updateNowPlayingField:MPMediaItemPropertyArtist value:mediaItem[@"artist"]]; + NSNumber *duration = mediaItem[@"duration"]; + if (duration == (id)[NSNull null]) duration = @(0); + updated |= [self updateNowPlayingField:MPMediaItemPropertyPlaybackDuration value:([NSNumber numberWithDouble: ([duration doubleValue] / 1000)])]; + if (@available(iOS 3.0, macOS 10.13.2, *)) { + updated |= [self updateNowPlayingField:MPMediaItemPropertyArtwork value:artwork]; + } + } + + if (@available(iOS 10.0, macOS 10.12.2, *)) { + updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyMediaType value:@(MPNowPlayingInfoMediaTypeAudio)]; + } + updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyPlaybackRate value:(playing ? speed : [NSNumber numberWithDouble: 0.0])]; + updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyDefaultPlaybackRate value:(playing ? speed : [NSNumber numberWithDouble: 0.0])]; + updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyElapsedPlaybackTime value:[NSNumber numberWithDouble:([position doubleValue] / 1000)]]; + MPNowPlayingInfoCenter *center = [MPNowPlayingInfoCenter defaultCenter]; +#if TARGET_OS_OSX + if (@available(iOS 13.0, macOS 10.12.2, *)) { + center.playbackState = playing ? MPNowPlayingPlaybackStatePlaying : MPNowPlayingPlaybackStatePaused; + } +#endif + if (@available(iOS 10.0, macOS 10.12.2, *)) { + updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyIsLiveStream value:mediaItem[@"isLive"]]; + } + if (updated) { + //NSLog(@"### updating nowPlayingInfo"); + center.nowPlayingInfo = nowPlayingInfo; + } + + // TODO: List of all unused "nowPlayingInfo" keys, we might want to use these at some point: + // + // * MPNowPlayingInfoCollectionIdentifier + // * MPNowPlayingInfoPropertyAvailableLanguageOptions + // * MPNowPlayingInfoPropertyAssetURL + // * MPNowPlayingInfoPropertyChapterCount + // * MPNowPlayingInfoPropertyChapterNumber + // * MPNowPlayingInfoPropertyCurrentLanguageOptions + // * MPNowPlayingInfoPropertyDefaultPlaybackRate + // * MPNowPlayingInfoPropertyCurrentPlaybackDate + // * MPNowPlayingInfoPropertyExternalContentIdentifier + // * MPNowPlayingInfoPropertyExternalUserProfileIdentifier + // * MPNowPlayingInfoPropertyPlaybackProgress + // * MPNowPlayingInfoPropertyPlaybackQueueCount + // * MPNowPlayingInfoPropertyPlaybackQueueIndex + // * MPNowPlayingInfoPropertyServiceIdentifier +} + +- (void) updateControls { + if (!commandCenter) return; + for (enum MediaAction action = AStop; action <= ASetSpeed; action++) { + [self updateControl:action]; + } + _controlsUpdated = YES; +} + +- (void) updateControl:(enum MediaAction)action { + MPRemoteCommand *command = commands[action]; + if (command == (id)[NSNull null]) return; + // Shift the actionBits right until the least significant bit is the tested action bit, and AND that with a 1 at the same position. + // All bytes become 0, other than the tested action bit, which will be 0 or 1 according to its status in the actionBits long. + BOOL enable = ((actionBits >> action) & 1); + if (_controlsUpdated && enable == command.enabled) return; + //NSLog(@"## updateControl %@ enable=%@", @(action), @(enable)); + [command setEnabled:enable]; + switch (action) { + case AStop: + if (enable) { + [commandCenter.stopCommand addTarget:self action:@selector(stop:)]; + } else { + [commandCenter.stopCommand removeTarget:nil]; + } + break; + case APause: + if (enable) { + [commandCenter.pauseCommand addTarget:self action:@selector(pause:)]; + } else { + [commandCenter.pauseCommand removeTarget:nil]; + } + break; + case APlay: + if (enable) { + [commandCenter.playCommand addTarget:self action:@selector(play:)]; + } else { + [commandCenter.playCommand removeTarget:nil]; + } + break; + case ARewind: + if (rewindInterval.integerValue > 0) { + if (enable) { + [commandCenter.skipBackwardCommand addTarget: self action:@selector(skipBackward:)]; + int rewindIntervalInSeconds = [rewindInterval intValue]/1000; + NSNumber *rewindIntervalInSec = [NSNumber numberWithInt: rewindIntervalInSeconds]; + commandCenter.skipBackwardCommand.preferredIntervals = @[rewindIntervalInSec]; + } else { + [commandCenter.skipBackwardCommand removeTarget:nil]; + } + } + break; + case ASkipToPrevious: + if (enable) { + [commandCenter.previousTrackCommand addTarget:self action:@selector(previousTrack:)]; + } else { + [commandCenter.previousTrackCommand removeTarget:nil]; + } + break; + case ASkipToNext: + if (enable) { + [commandCenter.nextTrackCommand addTarget:self action:@selector(nextTrack:)]; + } else { + [commandCenter.nextTrackCommand removeTarget:nil]; + } + break; + case AFastForward: + if (fastForwardInterval.integerValue > 0) { + if (enable) { + [commandCenter.skipForwardCommand addTarget: self action:@selector(skipForward:)]; + int fastForwardIntervalInSeconds = [fastForwardInterval intValue]/1000; + NSNumber *fastForwardIntervalInSec = [NSNumber numberWithInt: fastForwardIntervalInSeconds]; + commandCenter.skipForwardCommand.preferredIntervals = @[fastForwardIntervalInSec]; + } else { + [commandCenter.skipForwardCommand removeTarget:nil]; + } + } + break; + case ASetRating: + // TODO: + // commandCenter.ratingCommand + // commandCenter.dislikeCommand + // commandCenter.bookmarkCommand + break; + case ASeekTo: + if (@available(iOS 9.1, macOS 10.12.2, *)) { + if (enable) { + [commandCenter.changePlaybackPositionCommand addTarget:self action:@selector(changePlaybackPosition:)]; + } else { + [commandCenter.changePlaybackPositionCommand removeTarget:nil]; + } + } + break; + case APlayPause: + // Automatically enabled. + break; + case ASetRepeatMode: + if (enable) { + [commandCenter.changeRepeatModeCommand addTarget:self action:@selector(changeRepeatMode:)]; + } else { + [commandCenter.changeRepeatModeCommand removeTarget:nil]; + } + break; + case ASetShuffleMode: + if (enable) { + [commandCenter.changeShuffleModeCommand addTarget:self action:@selector(changeShuffleMode:)]; + } else { + [commandCenter.changeShuffleModeCommand removeTarget:nil]; + } + break; + case ASeekBackward: + if (enable) { + [commandCenter.seekBackwardCommand addTarget:self action:@selector(seekBackward:)]; + } else { + [commandCenter.seekBackwardCommand removeTarget:nil]; + } + break; + case ASeekForward: + if (enable) { + [commandCenter.seekForwardCommand addTarget:self action:@selector(seekForward:)]; + } else { + [commandCenter.seekForwardCommand removeTarget:nil]; + } + break; + case ASetSpeed: + if (enable) { +// [commandCenter.changePlaybackRateCommand setSupportedPlaybackRates:@[@(1),@(1.5),@(2)]] + [commandCenter.changePlaybackRateCommand addTarget:self action:@selector(changePlaybackRate:)]; + } else { + [commandCenter.changePlaybackRateCommand removeTarget:nil]; + } + break; + default: + break; + } +} + +- (MPRemoteCommandHandlerStatus) togglePlayPause: (MPRemoteCommandEvent *) event { + //NSLog(@"togglePlayPause"); + [handlerChannel invokeMethod:@"click" arguments:@{ + @"button":@(0) + }]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) stop: (MPRemoteCommandEvent *) event { + //NSLog(@"stop"); + [handlerChannel invokeMethod:@"stop" arguments:@{}]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) nextTrack: (MPRemoteCommandEvent *) event { + //NSLog(@"nextTrack"); + [handlerChannel invokeMethod:@"skipToNext" arguments:@{}]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) previousTrack: (MPRemoteCommandEvent *) event { + //NSLog(@"previousTrack"); + [handlerChannel invokeMethod:@"skipToPrevious" arguments:@{}]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) changePlaybackPosition: (MPChangePlaybackPositionCommandEvent *) event { + //NSLog(@"changePlaybackPosition"); + [handlerChannel invokeMethod:@"seek" arguments: @{ + @"position":@((long long) (event.positionTime * 1000000.0)) + }]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) skipForward: (MPRemoteCommandEvent *) event { + //NSLog(@"skipForward"); + [handlerChannel invokeMethod:@"fastForward" arguments:@{}]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) skipBackward: (MPRemoteCommandEvent *) event { + //NSLog(@"skipBackward"); + [handlerChannel invokeMethod:@"rewind" arguments:@{}]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) seekForward: (MPSeekCommandEvent *) event { + //NSLog(@"seekForward"); + BOOL begin = event.type == MPSeekCommandEventTypeBeginSeeking; + [handlerChannel invokeMethod:@"seekForward" arguments:@{ + @"begin":@(begin) + }]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) seekBackward: (MPSeekCommandEvent *) event { + //NSLog(@"seekBackward"); + BOOL begin = event.type == MPSeekCommandEventTypeBeginSeeking; + [handlerChannel invokeMethod:@"seekBackward" arguments:@{ + @"begin":@(begin) + }]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) changeRepeatMode: (MPChangeRepeatModeCommandEvent *) event { + //NSLog(@"changeRepeatMode"); + int modeIndex; + switch (event.repeatType) { + case MPRepeatTypeOff: + modeIndex = 0; + break; + case MPRepeatTypeOne: + modeIndex = 1; + break; + // MPRepeatTypeAll + default: + modeIndex = 2; + break; + } + [handlerChannel invokeMethod:@"setRepeatMode" arguments:@{ + @"repeatMode":@(modeIndex) + }]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) changePlaybackRate: (MPChangePlaybackRateCommandEvent *) event { + //NSLog(@"changePlaybackRate"); + [handlerChannel invokeMethod:@"setSpeed" arguments:@{ + @"speed":@(event.playbackRate) + }]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus) changeShuffleMode: (MPChangeShuffleModeCommandEvent *) event { + //NSLog(@"changeShuffleMode"); + int modeIndex; + switch (event.shuffleType) { + case MPShuffleTypeOff: + modeIndex = 0; + break; + case MPShuffleTypeItems: + modeIndex = 1; + break; + // MPShuffleTypeCollections + default: + modeIndex = 2; + break; + } + [handlerChannel invokeMethod:@"setShuffleMode" arguments:@{ + @"shuffleMode":@(modeIndex) + }]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (void) dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +@end diff --git a/third_party/audio_service/darwin/audio_service/Sources/audio_service/include/audio_service/AudioServicePlugin.h b/third_party/audio_service/darwin/audio_service/Sources/audio_service/include/audio_service/AudioServicePlugin.h new file mode 100644 index 00000000..3f704397 --- /dev/null +++ b/third_party/audio_service/darwin/audio_service/Sources/audio_service/include/audio_service/AudioServicePlugin.h @@ -0,0 +1,50 @@ +#import +#if TARGET_OS_OSX +#import +#else +#import +#endif + +@interface AudioServicePlugin : NSObject + +@property (readonly, nonatomic) FlutterMethodChannel *channel; + +@end + +enum AudioProcessingState { + ApsIdle, + ApsLoading, + ApsBuffering, + ApsReady, + ApsCompleted, + ApsError +}; + +enum MediaAction { + AStop, + APause, + APlay, + ARewind, + ASkipToPrevious, + ASkipToNext, + AFastForward, + ASetRating, + ASeekTo, + APlayPause, + APlayFromMediaId, + APlayFromSearch, + ASkipToQueueItem, + APlayFromUri, + APrepare, + APrepareFromMediaId, + APrepareFromSearch, + APrepareFromUri, + ASetRepeatMode, + ASetShuffleModeEnabled, // deprecated + ASetCaptioningEnabled, + ASetShuffleMode, + // iOS/macOS-specific + ASeekBackward, + ASeekForward, + ASetSpeed, +}; diff --git a/third_party/audio_service/lib/audio_service.dart b/third_party/audio_service/lib/audio_service.dart new file mode 100644 index 00000000..3104d7be --- /dev/null +++ b/third_party/audio_service/lib/audio_service.dart @@ -0,0 +1,4060 @@ +// ignore_for_file: close_sinks + +import 'dart:async'; +import 'dart:isolate'; +import 'dart:ui'; + +import 'package:audio_service_platform_interface/audio_service_platform_interface.dart'; +import 'package:audio_session/audio_session.dart'; +import 'package:clock/clock.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:rxdart/rxdart.dart'; + +AudioServicePlatform _platform = AudioServicePlatform.instance; + +/// The buttons on a headset. +enum MediaButton { + /// The "media" button on Android, or the play/pause button on iOS. + media, + + /// The "skip to next" button. + next, + + /// The "skip to previous" button. + previous, +} + +/// The actions associated with playing audio. +enum MediaAction { + /// Stop playing audio. + stop, + + /// Pause audio. + pause, + + /// Play or resume audio. + play, + + /// Rewind. + rewind, + + /// Skip to the previous media item. + skipToPrevious, + + /// Skip to the next media item. + skipToNext, + + /// Fast forward. + fastForward, + + /// Set a rating for the current media item. + setRating, + + /// Seek within the current media item. + seek, + + /// Toggle between playing and paused. + playPause, + + /// Play a given media item by ID. + playFromMediaId, + + /// Play media from a search. + playFromSearch, + + /// Skip to a queue item. + skipToQueueItem, + + /// Play media from a URI. + playFromUri, + + /// Prepare media for playback. + prepare, + + /// Prepare media for playback by ID. + prepareFromMediaId, + + /// Prepare media for playback from a search. + prepareFromSearch, + + /// Prepare media for playback from a URI. + prepareFromUri, + + /// Set the repeat mode. + setRepeatMode, + + /// Was depreceated in Android. + // ignore: unused_field + _setShuffleModeEnabled, + + /// Set captioning enabled. + setCaptioningEnabled, + + /// Set the shuffle mode. + setShuffleMode, + + /// Seek backwards continuously. + seekBackward, + + /// Seek forwards continuously. + seekForward, + + /// Set speed. + setSpeed, + + /// Custom MediaAction. + custom, +} + +/// The states of audio processing. +enum AudioProcessingState { + /// There hasn't been any resource loaded yet. + idle, + + /// Resource is being loaded. + loading, + + /// Resource is being buffered. + buffering, + + /// Resource is buffered enough and available for playback. + ready, + + /// The end of resource was reached. + completed, + + /// There was an error loading resource. + /// + /// [PlaybackState.errorCode] and [PlaybackState.errorMessage] will be not null + /// in this state. + error, +} + +/// The playback state 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. [AudioProcessingState.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 list of currently enabled controls which should be shown in the media + /// notification. Each control represents a clickable button with a + /// [MediaAction] that must be one of: + /// + /// * [MediaAction.stop] + /// * [MediaAction.pause] + /// * [MediaAction.play] + /// * [MediaAction.rewind] + /// * [MediaAction.skipToPrevious] + /// * [MediaAction.skipToNext] + /// * [MediaAction.fastForward] + /// * [MediaAction.playPause] + final List controls; + + /// Up to 3 indices of the [controls] that should appear in Android's compact + /// media notification view. When the notification is expanded, all [controls] + /// will be shown. + final List? androidCompactActionIndices; + + /// The set of system actions currently enabled. This is for specifying any + /// other [MediaAction]s that are not supported by [controls], because they do + /// not represent clickable buttons. For example: + /// + /// * [MediaAction.seek] (enable a seek bar) + /// * [MediaAction.seekForward] (enable press-and-hold fast-forward control) + /// * [MediaAction.seekBackward] (enable press-and-hold rewind control) + /// + /// Note that specifying [MediaAction.seek] in [systemActions] will enable a + /// seek bar in both the Android notification and the iOS control center, but + /// on Android, it will show only if the media item's duration has been set. + /// [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. + /// + /// When enabling the seek bar, also note that some Android devices will not + /// render the seek bar correctly unless your [AudioServiceConfig.androidNotificationIcon] + /// is a monochrome white icon on a transparent background, and your + /// [AudioServiceConfig.notificationColor] is a non-transparent color. + final Set systemActions; + + /// The playback position at [updateTime]. + /// + /// For efficiency, the [updatePosition] 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 the [position] getter. + final Duration updatePosition; + + /// 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 DateTime updateTime; + + /// The error code when [processingState] is [AudioProcessingState.error]. + final int? errorCode; + + /// The error message when [processingState] is [AudioProcessingState.error]. + final String? errorMessage; + + /// The current repeat mode. + final AudioServiceRepeatMode repeatMode; + + /// The current shuffle mode. + final AudioServiceShuffleMode shuffleMode; + + /// Whether captioning is enabled. + final bool captioningEnabled; + + /// The index of the current item in the queue, if any. + final int? queueIndex; + + /// Creates a [PlaybackState] with given field values, and with [updateTime] + /// defaulting to [DateTime.now]. + PlaybackState({ + this.processingState = AudioProcessingState.idle, + this.playing = false, + this.controls = const [], + this.androidCompactActionIndices, + this.systemActions = const {}, + this.updatePosition = Duration.zero, + this.bufferedPosition = Duration.zero, + this.speed = 1.0, + DateTime? updateTime, + this.errorCode, + this.errorMessage, + this.repeatMode = AudioServiceRepeatMode.none, + this.shuffleMode = AudioServiceShuffleMode.none, + this.captioningEnabled = false, + this.queueIndex, + }) : assert(androidCompactActionIndices == null || + androidCompactActionIndices.length <= 3), + updateTime = updateTime ?? clock.now(); + + /// Creates a copy of this state with given fields replaced by new values, + /// with [updateTime] set to [DateTime.now], and unless otherwise replaced, + /// with [updatePosition] set to [position]. + /// + /// The [errorCode] and [errorMessage] will be set to null unless [processingState] is + /// [AudioProcessingState.error]. + PlaybackStateCopyWith get copyWith => _PlaybackStateCopyWith(this); + + /// The current playback position. + Duration get position { + if (playing && processingState == AudioProcessingState.ready) { + return Duration( + milliseconds: (updatePosition.inMilliseconds + + speed * + (clock.now().millisecondsSinceEpoch - + updateTime.millisecondsSinceEpoch)) + .toInt(), + ); + } else { + return updatePosition; + } + } + + PlaybackStateMessage _toMessage() => PlaybackStateMessage( + processingState: + AudioProcessingStateMessage.values[processingState.index], + playing: playing, + controls: controls.map((control) => control._toMessage()).toList(), + androidCompactActionIndices: androidCompactActionIndices, + systemActions: systemActions + .map((action) => MediaActionMessage.values[action.index]) + .toSet(), + updatePosition: updatePosition, + bufferedPosition: bufferedPosition, + speed: speed, + updateTime: updateTime, + errorCode: errorCode, + errorMessage: errorMessage, + repeatMode: AudioServiceRepeatModeMessage.values[repeatMode.index], + shuffleMode: AudioServiceShuffleModeMessage.values[shuffleMode.index], + captioningEnabled: captioningEnabled, + queueIndex: queueIndex, + ); + + @override + String toString() => '${_toMessage().toMap()}'; + + @override + int get hashCode => Object.hash( + processingState, + playing, + Object.hashAll(controls), + androidCompactActionIndices != null + ? Object.hashAll(androidCompactActionIndices!) + : 0, + Object.hashAll(systemActions), + updatePosition, + bufferedPosition, + speed, + updateTime, + errorCode, + errorMessage, + repeatMode, + shuffleMode, + captioningEnabled, + queueIndex, + ); + + @override + bool operator ==(Object other) => + identical(other, this) || + other.runtimeType == runtimeType && + other is PlaybackState && + processingState == other.processingState && + playing == other.playing && + listEquals(controls, other.controls) && + listEquals( + androidCompactActionIndices, other.androidCompactActionIndices) && + setEquals(systemActions, other.systemActions) && + updatePosition == other.updatePosition && + bufferedPosition == other.bufferedPosition && + speed == other.speed && + updateTime == other.updateTime && + errorCode == other.errorCode && + errorMessage == other.errorMessage && + repeatMode == other.repeatMode && + shuffleMode == other.shuffleMode && + captioningEnabled == other.captioningEnabled && + queueIndex == other.queueIndex; +} + +/// The `copyWith` function type for [PlaybackState]. +abstract class PlaybackStateCopyWith { + /// Calls this function. + PlaybackState call({ + AudioProcessingState processingState, + bool playing, + List controls, + List? androidCompactActionIndices, + Set systemActions, + Duration updatePosition, + Duration bufferedPosition, + double speed, + int? errorCode, + String? errorMessage, + AudioServiceRepeatMode repeatMode, + AudioServiceShuffleMode shuffleMode, + bool captioningEnabled, + int? queueIndex, + }); +} + +/// The implementation of [PlaybackState]'s `copyWith` function allowing +/// parameters to be explicitly set to null. +class _PlaybackStateCopyWith extends PlaybackStateCopyWith { + static const _fakeNull = Object(); + + /// The [PlaybackState] object this function applies to. + final PlaybackState value; + + _PlaybackStateCopyWith(this.value); + + @override + PlaybackState call({ + Object? processingState = _fakeNull, + Object? playing = _fakeNull, + Object? controls = _fakeNull, + Object? androidCompactActionIndices = _fakeNull, + Object? systemActions = _fakeNull, + Object? updatePosition = _fakeNull, + Object? bufferedPosition = _fakeNull, + Object? speed = _fakeNull, + Object? errorCode = _fakeNull, + Object? errorMessage = _fakeNull, + Object? repeatMode = _fakeNull, + Object? shuffleMode = _fakeNull, + Object? captioningEnabled = _fakeNull, + Object? queueIndex = _fakeNull, + }) => + PlaybackState( + processingState: processingState == _fakeNull + ? value.processingState + : processingState as AudioProcessingState, + playing: playing == _fakeNull ? value.playing : playing as bool, + controls: controls == _fakeNull + ? value.controls + : controls as List, + androidCompactActionIndices: androidCompactActionIndices == _fakeNull + ? value.androidCompactActionIndices + : androidCompactActionIndices as List?, + systemActions: systemActions == _fakeNull + ? value.systemActions + : systemActions as Set, + updatePosition: updatePosition == _fakeNull + ? value.updatePosition + : updatePosition as Duration, + bufferedPosition: bufferedPosition == _fakeNull + ? value.bufferedPosition + : bufferedPosition as Duration, + speed: speed == _fakeNull ? value.speed : speed as double, + errorCode: errorCode == _fakeNull ? value.errorCode : errorCode as int?, + errorMessage: errorMessage == _fakeNull + ? value.errorMessage + : errorMessage as String?, + repeatMode: repeatMode == _fakeNull + ? value.repeatMode + : repeatMode as AudioServiceRepeatMode, + shuffleMode: shuffleMode == _fakeNull + ? value.shuffleMode + : shuffleMode as AudioServiceShuffleMode, + captioningEnabled: captioningEnabled == _fakeNull + ? value.captioningEnabled + : captioningEnabled as bool, + queueIndex: + queueIndex == _fakeNull ? value.queueIndex : queueIndex as int?, + ); +} + +/// The style of a [Rating]. +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 Object? _value; + + const Rating._(this._type, this._value); + + /// Creates a new heart rating. + const Rating.newHeartRating(bool hasHeart) + : this._(RatingStyle.heart, hasHeart); + + /// Creates a new percentage rating. + const Rating.newPercentageRating(double percent) + : assert( + percent >= 0 && percent <= 100, + 'Percentage must be in range from 0 to 100', + ), + _type = RatingStyle.percentage, + _value = percent; + + /// Creates a new star rating. + Rating.newStarRating(RatingStyle style, int rating) + : assert( + style == RatingStyle.range3stars || + style == RatingStyle.range4stars || + style == RatingStyle.range5stars, + 'Invalid rating style', + ), + assert(rating >= 0 && rating <= style.index), + _type = style, + _value = rating; + + /// Creates a new thumb rating. + const Rating.newThumbRating(bool isThumbsUp) + : this._(RatingStyle.thumbUpDown, isThumbsUp); + + /// Creates a new unrated rating. + const Rating.newUnratedRating(RatingStyle ratingStyle) + : this._(ratingStyle, null); + + /// Return the rating style. + RatingStyle getRatingStyle() => _type; + + /// Returns a percentage rating value greater or equal to `0.0`, 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; + final localValue = _value as double?; + if (localValue == null || localValue < 0 || localValue > 100) return -1; + return localValue; + } + + /// Returns a rating value greater or equal to `0.0`, 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 as int? ?? -1; + } + + /// Returns true if the rating is "heart selected" or false if the + /// rating is "heart unselected", if the rating style is not [RatingStyle.heart] + /// or if it is unrated. + bool hasHeart() { + if (_type != RatingStyle.heart) return false; + return _value as bool? ?? false; + } + + /// Returns true if the rating is "thumb up" or false if the rating + /// is "thumb down", if the rating style is not [RatingStyle.thumbUpDown] or if + /// it is unrated. + bool isThumbUp() { + if (_type != RatingStyle.thumbUpDown) return false; + return _value as bool? ?? false; + } + + /// Return whether there is a rating value available. + bool isRated() => _value != null; + + RatingMessage _toMessage() => RatingMessage( + type: RatingStyleMessage.values[_type.index], + value: _value, + ); + + @override + String toString() => '${_toMessage().toMap()}'; + + @override + int get hashCode => Object.hash(_value, _type); + + @override + bool operator ==(Object other) => + other.runtimeType == runtimeType && + other is Rating && + _type == other._type && + _value == other._value; +} + +/// Metadata of an audio item that can be played, or a folder containing +/// audio items. +class MediaItem { + /// A unique id. + final String id; + + /// The title of this media item. + final String title; + + /// The album this media item belongs to. + final String? album; + + /// 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 URI for this media item. + /// + /// Supported types of URIs are: + /// + /// * File - file:// + /// * Network - http:// https:// etc. + /// * Android content URIs - content:// + /// + /// ## Speeding up Android content URI loading + /// + /// For Android content:// URIs, the plugin by default uses + /// `ContentResolver.openFileDescriptor`, which takes the direct URI of an + /// image. + /// + /// On Android API >= 29 there is `ContentResolver.loadThumbnail` function + /// which takes a URI of some content (for example, a song from `MediaStore`), + /// and returns a thumbnail for it. + /// + /// It is noticeably faster to use this function. You can enable this by + /// putting a `loadThumbnailUri` key into the [extras]. If `loadThumbnail` is + /// not available, it will just fallback to using `openFileDescriptor`. + final Uri? artUri; + + /// The HTTP headers to use when sending an HTTP request for [artUri]. + final Map? artHeaders; + + /// 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 media item. + final Rating? rating; + + /// Whether this is a live stream. + final bool? isLive; + + /// A map of additional metadata for the media item. + /// + /// The values must be of type `int`, `String`, `bool` or `double`. + final Map? extras; + + /// Creates a [MediaItem]. + /// + /// The [id] must be unique for each instance. + const MediaItem({ + required this.id, + required this.title, + this.album, + this.artist, + this.genre, + this.duration, + this.artUri, + this.artHeaders, + this.playable = true, + this.displayTitle, + this.displaySubtitle, + this.displayDescription, + this.rating, + this.isLive, + this.extras, + }); + + /// Creates a copy of this [MediaItem] with with the given fields replaced by + /// new values. + MediaItemCopyWith get copyWith => _MediaItemCopyWith(this); + + @override + int get hashCode => id.hashCode; + + @override + bool operator ==(Object other) => + other.runtimeType == runtimeType && other is MediaItem && other.id == id; + + MediaItemMessage _toMessage() => MediaItemMessage( + id: id, + album: album, + title: title, + artist: artist, + genre: genre, + duration: duration, + artUri: artUri, + playable: playable, + displayTitle: displayTitle, + displaySubtitle: displaySubtitle, + displayDescription: displayDescription, + rating: rating?._toMessage(), + isLive: isLive, + extras: extras, + ); + + @override + String toString() => '${_toMessage().toMap()}'; +} + +/// The `copyWith` function type for [MediaItem]. +abstract class MediaItemCopyWith { + /// Calls this function. + MediaItem call({ + String id, + String title, + String? album, + String? artist, + String? genre, + Duration? duration, + Uri? artUri, + bool? playable, + String? displayTitle, + String? displaySubtitle, + String? displayDescription, + Rating? rating, + bool? isLive, + Map? extras, + }); +} + +/// The implementation of [MediaItem]'s `copyWith` function allowing +/// parameters to be explicitly set to null. +class _MediaItemCopyWith extends MediaItemCopyWith { + static const _fakeNull = Object(); + + /// The [MediaItem] object this function applies to. + final MediaItem value; + + _MediaItemCopyWith(this.value); + + @override + MediaItem call({ + Object? id = _fakeNull, + Object? title = _fakeNull, + Object? album = _fakeNull, + Object? artist = _fakeNull, + Object? genre = _fakeNull, + Object? duration = _fakeNull, + Object? artUri = _fakeNull, + Object? playable = _fakeNull, + Object? displayTitle = _fakeNull, + Object? displaySubtitle = _fakeNull, + Object? displayDescription = _fakeNull, + Object? rating = _fakeNull, + Object? isLive = _fakeNull, + Object? extras = _fakeNull, + }) => + MediaItem( + id: id == _fakeNull ? value.id : id as String, + title: title == _fakeNull ? value.title : title as String, + album: album == _fakeNull ? value.album : album as String?, + artist: artist == _fakeNull ? value.artist : artist as String?, + genre: genre == _fakeNull ? value.genre : genre as String?, + duration: + duration == _fakeNull ? value.duration : duration as Duration?, + artUri: artUri == _fakeNull ? value.artUri : artUri as Uri?, + playable: playable == _fakeNull ? value.playable : playable as bool?, + displayTitle: displayTitle == _fakeNull + ? value.displayTitle + : displayTitle as String?, + displaySubtitle: displaySubtitle == _fakeNull + ? value.displaySubtitle + : displaySubtitle as String?, + displayDescription: displayDescription == _fakeNull + ? value.displayDescription + : displayDescription as String?, + rating: rating == _fakeNull ? value.rating : rating as Rating?, + isLive: isLive == _fakeNull ? value.isLive : isLive as bool?, + extras: extras == _fakeNull + ? value.extras + : extras as Map?, + ); +} + +/// Custom action information used to define an action name and optional extras +/// that are sent to [AudioHandler.customAction] when the associated media control is used. +class CustomMediaAction { + /// Custom action name + final String name; + + /// A map of additional data for the custom action. + /// + /// The values must be integers or strings. + final Map? extras; + + /// Creates a [CustomMediaAction]. + const CustomMediaAction({required this.name, this.extras}); + + /// Convert to a Map. + Map toMap() => { + 'name': name, + 'extras': extras, + }; + + CustomMediaActionMessage _toMessage() => CustomMediaActionMessage( + name: name, + extras: extras, + ); + + @override + int get hashCode => Object.hash(name, extras); + + @override + bool operator ==(Object other) => + other.runtimeType == runtimeType && + other is CustomMediaAction && + name == other.name && + mapEquals(extras, other.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 streamed via [AudioHandler.playbackState]. +/// +/// 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 const stop = MediaControl( + androidIcon: 'drawable/audio_service_stop', + label: 'Stop', + action: MediaAction.stop, + ); + + /// A default control for [MediaAction.pause]. + static const pause = MediaControl( + androidIcon: 'drawable/audio_service_pause', + label: 'Pause', + action: MediaAction.pause, + ); + + /// A default control for [MediaAction.play]. + static const play = MediaControl( + androidIcon: 'drawable/audio_service_play_arrow', + label: 'Play', + action: MediaAction.play, + ); + + /// A default control for [MediaAction.rewind]. + static const rewind = MediaControl( + androidIcon: 'drawable/audio_service_fast_rewind', + label: 'Rewind', + action: MediaAction.rewind, + ); + + /// A default control for [MediaAction.skipToNext]. + static const skipToNext = MediaControl( + androidIcon: 'drawable/audio_service_skip_next', + label: 'Next', + action: MediaAction.skipToNext, + ); + + /// A default control for [MediaAction.skipToPrevious]. + static const skipToPrevious = MediaControl( + androidIcon: 'drawable/audio_service_skip_previous', + label: 'Previous', + action: MediaAction.skipToPrevious, + ); + + /// A default control for [MediaAction.fastForward]. + static const 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; + + /// The custom action name and optional extras to receive in + /// [AudioHandler.customAction] + final CustomMediaAction? customAction; + + /// Creates a custom [MediaControl]. + MediaControl.custom({ + required this.androidIcon, + required this.label, + required String name, + Map? extras, + }) : action = MediaAction.custom, + customAction = CustomMediaAction(name: name, extras: extras) { + assert(action != MediaAction.custom || customAction != null); + } + + /// Creates a custom [MediaControl]. + const MediaControl({ + required this.androidIcon, + required this.label, + required this.action, + this.customAction, + }); + + /// Creates a copy of this control with given fields replaced by new values. + MediaControl copyWith({ + String? androidIcon, + String? label, + MediaAction? action, + CustomMediaAction? customAction, + }) => + MediaControl( + androidIcon: androidIcon ?? this.androidIcon, + label: label ?? this.label, + action: action ?? this.action, + customAction: customAction ?? this.customAction, + ); + + MediaControlMessage _toMessage() => MediaControlMessage( + androidIcon: androidIcon, + label: label, + action: MediaActionMessage.values[action.index], + customAction: customAction?._toMessage(), + ); + + @override + String toString() => '${_toMessage().toMap()}'; + + @override + int get hashCode => Object.hash(androidIcon, label, action); + + @override + bool operator ==(Object other) => + other.runtimeType == runtimeType && + other is MediaControl && + androidIcon == other.androidIcon && + label == other.label && + action == other.action; +} + +/// Provides an API to manage the app's [AudioHandler]. An app must call [init] +/// during initialisation to register the [AudioHandler] that will service all +/// requests to play audio. +class AudioService { + /// The cache to use when loading artwork. + /// Defaults to [DefaultCacheManager]. + static BaseCacheManager get cacheManager => _cacheManager!; + static BaseCacheManager? _cacheManager; + + static late AudioServiceConfig _config; + static late AudioHandler _handler; + + /// The current configuration. + static AudioServiceConfig get config => _config; + + /// The root media ID for browsing media provided by the background + /// task. + static const String browsableRootId = 'root'; + + /// The root media ID for browsing the most recently played item(s). + static const String recentRootId = 'recent'; + + static final BehaviorSubject _notificationClicked = + BehaviorSubject.seeded(false); + + /// A stream that broadcasts the status of the notificationClick event. + static ValueStream get notificationClicked => _notificationClicked; + + static final _asyncError = PublishSubject(); + + /// A stream that broadcasts any exceptions that occur asynchronously. + static Stream get asyncError => _asyncError; + + static final _compatibilitySwitcher = SwitchAudioHandler(); + + /// Register the app's [AudioHandler] with configuration options. This must be + /// called once during the app's initialisation so that it is prepared to + /// handle audio requests immediately after a cold restart (e.g. if the user + /// clicks on the play button in the media notification while your app is not + /// running and your app needs to be woken up). + /// + /// You may optionally specify a [cacheManager] to use when loading artwork to + /// display in the media notification and lock screen. This defaults to + /// [DefaultCacheManager]. + /// + /// This may throw a [PlatformException] on Android if you have not set the + /// correct Service or Activity in your `AndroidManifest.xml` file or if your + /// Activity does not provide the correct `FlutterEngine`. + static Future init({ + required T Function() builder, + AudioServiceConfig? config, + BaseCacheManager? cacheManager, + }) async { + assert(_cacheManager == null); + config ??= const AudioServiceConfig(); + assert(config.fastForwardInterval > Duration.zero); + assert(config.rewindInterval > Duration.zero); + WidgetsFlutterBinding.ensureInitialized(); + _cacheManager = (cacheManager ??= DefaultCacheManager()); + final callbacks = _HandlerCallbacks(); + _platform.setHandlerCallbacks(callbacks); + await _platform.configure(ConfigureRequest(config: config._toMessage())); + _config = config; + final handler = builder(); + _handler = handler; + callbacks.setHandler(handler); + + _observeMediaItem(); + _observeAndroidPlaybackInfo(); + _observeQueue(); + _observePlaybackState(); + + return handler; + } + + static Future _observeMediaItem() async { + Object? artFetchOperationId; + _handler.mediaItem.listen((mediaItem) async { + if (mediaItem == null) { + return; + } + final operationId = Object(); + artFetchOperationId = operationId; + final artUri = mediaItem.artUri; + if (artUri == null || artUri.scheme == 'content') { + _platform + .setMediaItem( + SetMediaItemRequest(mediaItem: mediaItem._toMessage())) + .catchError(_asyncError.add); + } else { + /// Sends media item to the platform. + /// We potentially need to fetch the art before that. + Future sendToPlatform(String? filePath) async { + final extras = mediaItem.extras; + final platformMediaItem = mediaItem.copyWith( + extras: { + if (extras != null) ...extras, + 'artCacheFile': filePath, + }, + ); + await _platform.setMediaItem( + SetMediaItemRequest(mediaItem: platformMediaItem._toMessage())); + } + + if (artUri.scheme == 'file') { + sendToPlatform(artUri.toFilePath()).catchError(_asyncError.add); + } else { + // Try to load a cached file from memory. + final fileInfo = + await cacheManager.getFileFromMemory(artUri.toString()); + final filePath = fileInfo?.file.path; + if (operationId != artFetchOperationId) { + return; + } + + if (filePath != null) { + // If we successfully downloaded the art call to platform. + sendToPlatform(filePath).catchError(_asyncError.add); + } else { + // We haven't fetched the art yet, so show the metadata now, and again + // after we load the art. + try { + await _platform.setMediaItem( + SetMediaItemRequest(mediaItem: mediaItem._toMessage())); + } catch (e) { + _asyncError.add(e); + return; + } + if (operationId != artFetchOperationId) { + return; + } + // Load the art. + final loadedFilePath = await _loadArtwork(mediaItem); + if (operationId != artFetchOperationId) { + return; + } + // If we successfully downloaded the art, call to platform. + if (loadedFilePath != null) { + sendToPlatform(loadedFilePath).catchError(_asyncError.add); + } + } + } + } + }); + } + + static Future _observeAndroidPlaybackInfo() async { + await for (var playbackInfo in _handler.androidPlaybackInfo) { + try { + await _platform.setAndroidPlaybackInfo(SetAndroidPlaybackInfoRequest( + playbackInfo: playbackInfo._toMessage(), + )); + } catch (e) { + _asyncError.add(e); + } + } + } + + static Future _observeQueue() async { + await for (var queue in _handler.queue) { + if (_config.preloadArtwork) { + _loadAllArtwork(queue); + } + try { + await _platform.setQueue(SetQueueRequest( + queue: queue.map((item) => item._toMessage()).toList())); + } catch (e) { + _asyncError.add(e); + } + } + } + + static Future _observePlaybackState() async { + var previousState = _handler.playbackState.nvalue; + await for (var playbackState in _handler.playbackState) { + try { + await _platform + .setState(SetStateRequest(state: playbackState._toMessage())); + if (playbackState.processingState == AudioProcessingState.idle && + previousState?.processingState != AudioProcessingState.idle) { + await AudioService._stop(); + } + previousState = playbackState; + } catch (e) { + _asyncError.add(e); + } + } + } + + /// A stream tracking the current position, suitable for animating a seek bar. + /// To ensure a smooth animation, this stream emits values more frequently on + /// short media items where the seek bar moves more quickly, and less + /// frequenly on long media items where the seek bar moves more slowly. The + /// interval between each update will be no quicker than once every 16ms and + /// no slower than once every 200ms. + /// + /// See [createPositionStream] for more control over the stream parameters. + static final Stream position = createPositionStream( + steps: 800, + minPeriod: const Duration(milliseconds: 16), + maxPeriod: const Duration(milliseconds: 200)); + + /// Creates a new stream periodically tracking the current position. The + /// stream will aim to emit [steps] position updates at intervals of + /// current [MediaItem.duration] / [steps]. This interval will be clipped between [minPeriod] + /// and [maxPeriod]. This stream will not emit values while audio playback is + /// paused or stalled. + /// + /// Note: each time this method is called, a new stream is created. If you + /// intend to use this stream multiple times, you should hold a reference to + /// the returned stream. + static Stream createPositionStream({ + int steps = 800, + Duration minPeriod = const Duration(milliseconds: 200), + Duration maxPeriod = const Duration(milliseconds: 200), + }) { + assert(minPeriod <= maxPeriod); + assert(minPeriod > Duration.zero); + Duration? last; + late StreamController controller; + late StreamSubscription mediaItemSubscription; + late StreamSubscription playbackStateSubscription; + Timer? currentTimer; + Duration duration() => _handler.mediaItem.nvalue?.duration ?? Duration.zero; + Duration step() { + var s = duration() ~/ steps; + if (s < minPeriod) s = minPeriod; + if (s > maxPeriod) s = maxPeriod; + return s; + } + + void yieldPosition(Timer? timer) { + if (last != _handler.playbackState.nvalue?.position) { + controller.add((last = _handler.playbackState.nvalue?.position)!); + } + } + + controller = StreamController.broadcast( + sync: true, + onListen: () { + mediaItemSubscription = + _handler.mediaItem.listen((MediaItem? mediaItem) { + // Potentially a new duration + currentTimer?.cancel(); + currentTimer = Timer.periodic(step(), yieldPosition); + }); + playbackStateSubscription = + _handler.playbackState.listen((PlaybackState state) { + // Potentially a time discontinuity + yieldPosition(currentTimer); + }); + }, + onCancel: () { + mediaItemSubscription.cancel(); + playbackStateSubscription.cancel(); + }, + ); + + return controller.stream; + } + + /// 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 _platform.androidForceEnableMediaButtons( + const AndroidForceEnableMediaButtonsRequest(), + ); + } + + /// Stops the service. + static Future _stop() async { + await _platform.stopService(const StopServiceRequest()); + } + + 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) { + if (artUri.scheme == 'file') { + return artUri.toFilePath(); + } else { + final headers = mediaItem.artHeaders; + final file = headers != null + ? await cacheManager.getSingleFile(mediaItem.artUri!.toString(), + headers: headers) + : await cacheManager.getSingleFile(mediaItem.artUri!.toString()); + return file.path; + } + } + } catch (e, st) { + // TODO: handle this somehow? + // ignore: avoid_print + print('Error loading artUri: $e\n$st'); + } + return null; + } + + // DEPRECATED members + + /// Deprecated. Use [browsableRootId] instead. + @Deprecated("Use browsableRootId instead.") + // ignore: constant_identifier_names + static const String MEDIA_ROOT_ID = browsableRootId; + + static final _browseMediaChildrenSubject = BehaviorSubject>(); + + /// Deprecated. Directly subscribe to a parent's children via + /// [AudioHandler.subscribeToChildren]. + @Deprecated("Use AudioHandler.subscribeToChildren instead.") + static Stream> get browseMediaChildrenStream => + _browseMediaChildrenSubject.stream; + + /// Deprecated. Use [AudioHandler.getChildren] instead. + @Deprecated("Use AudioHandler.getChildren instead") + static List? get browseMediaChildren => + _browseMediaChildrenSubject.nvalue; + + /// Deprecated. Use [AudioHandler.playbackState] instead. + @Deprecated("Use AudioHandler.playbackState instead.") + static ValueStream get playbackStateStream => + _compatibilitySwitcher.playbackState; + + /// Deprecated. Use [notificationClicked] instead. + @Deprecated("Use notificationClicked instead.") + static ValueStream get notificationClickEventStream => + notificationClicked; + + /// Deprecated. Use `value` of [AudioHandler.playbackState] instead. + @Deprecated("Use AudioHandler.playbackState.value instead.") + static PlaybackState get playbackState => + _compatibilitySwitcher.playbackState.nvalue ?? PlaybackState(); + + /// Deprecated. Use [AudioHandler.mediaItem] instead. + @Deprecated("Use AudioHandler.mediaItem instead.") + static ValueStream get currentMediaItemStream => + _compatibilitySwitcher.mediaItem; + + /// Deprecated. Use `value` of [AudioHandler.mediaItem] instead. + @Deprecated("Use AudioHandler.mediaItem.value instead.") + static MediaItem? get currentMediaItem => + _compatibilitySwitcher.mediaItem.nvalue; + + /// Deprecated. Use [AudioHandler.queue] instead. + @Deprecated("Use AudioHandler.queue instead.") + static ValueStream?> get queueStream => + _compatibilitySwitcher.queue; + + /// Deprecated. Use `value` of [AudioHandler.queue] instead. + @Deprecated("Use AudioHandler.queue.value instead.") + static List? get queue => _compatibilitySwitcher.queue.nvalue; + + /// Deprecated. Use [AudioHandler.customEvent] instead. + @Deprecated("Use AudioHandler.customEvent instead.") + static Stream get customEventStream => + _compatibilitySwitcher.customEvent; + + /// Deprecated. Use [AudioHandler.playbackState] instead. + @Deprecated("Use AudioHandler.playbackState instead.") + static ValueStream get runningStream => playbackStateStream + .map((state) => state.processingState != AudioProcessingState.idle) + as ValueStream; + + /// Deprecated. Use [PlaybackState.processingState] of [AudioHandler.playbackState] instead. + @Deprecated("Use PlaybackState.processingState instead.") + static bool get running => runningStream.nvalue ?? false; + + static StreamSubscription>? _childrenSubscription; + + /// Deprecated. The new [AudioHandler] API now automatically starts the + /// service when your implementation enters the playing state. Parameters can + /// be passed via [AudioHandler.customAction]. + @Deprecated("Use init instead.") + static Future start({ + required Function backgroundTaskEntrypoint, + Map? params, + String androidNotificationChannelName = "Notifications", + String? androidNotificationChannelDescription, + int? androidNotificationColor, + String androidNotificationIcon = 'mipmap/ic_launcher', + bool androidShowNotificationBadge = false, + 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 (!androidEnableQueue) { + // ignore: avoid_print + print('NOTE: androidEnableQueue is always true from 0.18.0 onwards.'); + } + if (_cacheManager != null && _handler.playbackState.hasValue) { + if (_handler.playbackState.nvalue!.processingState != + AudioProcessingState.idle) { + return false; + } + } + + AudioServiceBackground._startCompleter = Completer(); + backgroundTaskEntrypoint(); + final task = await AudioServiceBackground._startCompleter!.future; + task._handler = _BackgroundAudioHandler(); + task._handler._task = task; + AudioServiceBackground._startCompleter = null; + + if (_cacheManager == null) { + _compatibilitySwitcher.inner = task._handler; + await init( + builder: () => _compatibilitySwitcher, + config: AudioServiceConfig( + androidResumeOnClick: androidResumeOnClick, + androidNotificationChannelName: androidNotificationChannelName, + androidNotificationChannelDescription: + androidNotificationChannelDescription, + notificationColor: androidNotificationColor != null + ? Color(androidNotificationColor) + : null, + androidNotificationIcon: androidNotificationIcon, + androidShowNotificationBadge: androidShowNotificationBadge, + androidNotificationClickStartsActivity: + androidNotificationClickStartsActivity, + androidNotificationOngoing: androidNotificationOngoing, + androidStopForegroundOnPause: androidStopForegroundOnPause, + artDownscaleWidth: androidArtDownscaleSize?.width.round(), + artDownscaleHeight: androidArtDownscaleSize?.height.round(), + fastForwardInterval: fastForwardInterval, + rewindInterval: rewindInterval, + ), + ); + } else { + _compatibilitySwitcher.inner = task._handler; + } + await task.onStart(params); + return true; + } + + /// Deprecated. Instead, subscribe directly to a parent's children via + /// [AudioHandler.subscribeToChildren]. + @Deprecated("Use AudioHandler.subscribeToChildren instead.") + static Future setBrowseMediaParent( + [String parentMediaId = browsableRootId]) async { + _childrenSubscription?.cancel(); + _childrenSubscription = _compatibilitySwitcher + .subscribeToChildren(parentMediaId) + .listen((Map? options) async { + _browseMediaChildrenSubject + .add(await _compatibilitySwitcher.getChildren(parentMediaId)); + }); + } + + /// Deprecated. Use [AudioHandler.addQueueItem] instead. + @Deprecated("Use AudioHandler.addQueueItem instead.") + static final addQueueItem = _compatibilitySwitcher.addQueueItem; + + /// Deprecated. Use [AudioHandler.insertQueueItem] instead. + @Deprecated("Use AudioHandler.insertQueueItem instead.") + static Future addQueueItemAt(MediaItem mediaItem, int index) async { + await _compatibilitySwitcher.insertQueueItem(index, mediaItem); + } + + /// Deprecated. Use [AudioHandler.removeQueueItem] instead. + @Deprecated("Use AudioHandler.removeQueueItem instead.") + static final removeQueueItem = _compatibilitySwitcher.removeQueueItem; + + /// Deprecated. Use [AudioHandler.addQueueItems] instead. + @Deprecated("Use AudioHandler.addQueueItems instead.") + static Future addQueueItems(List mediaItems) async { + for (var mediaItem in mediaItems) { + await addQueueItem(mediaItem); + } + } + + /// Deprecated. Use [AudioHandler.updateQueue] instead. + @Deprecated("Use AudioHandler.updateQueue instead.") + static final updateQueue = _compatibilitySwitcher.updateQueue; + + /// Deprecated. Use [AudioHandler.updateMediaItem] instead. + @Deprecated("Use AudioHandler.updateMediaItem instead.") + static final updateMediaItem = _compatibilitySwitcher.updateMediaItem; + + /// Deprecated. Use [AudioHandler.click] instead. + @Deprecated("Use AudioHandler.click instead.") + static final Future Function([MediaButton]) click = + _compatibilitySwitcher.click; + + /// Deprecated. Use [AudioHandler.prepare] instead. + @Deprecated("Use AudioHandler.prepare instead.") + static final prepare = _compatibilitySwitcher.prepare; + + /// Deprecated. Use [AudioHandler.prepareFromMediaId] instead. + @Deprecated("Use AudioHandler.prepareFromMediaId instead.") + static final Future Function(String, [Map]) + prepareFromMediaId = _compatibilitySwitcher.prepareFromMediaId; + + /// Deprecated. Use [AudioHandler.play] instead. + @Deprecated("Use AudioHandler.play instead.") + static final play = _compatibilitySwitcher.play; + + /// Deprecated. Use [AudioHandler.playFromMediaId] instead. + @Deprecated("Use AudioHandler.playFromMediaId instead.") + static final Future Function(String, [Map]) + playFromMediaId = _compatibilitySwitcher.playFromMediaId; + + /// Deprecated. Use [AudioHandler.playMediaItem] instead. + @Deprecated("Use AudioHandler.playMediaItem instead.") + static final playMediaItem = _compatibilitySwitcher.playMediaItem; + + /// Deprecated. Use [AudioHandler.skipToQueueItem] instead. + @Deprecated("Use AudioHandler.skipToQueueItem instead.") + static Future skipToQueueItem(String mediaId) async { + final queue = _compatibilitySwitcher.queue.nvalue!; + final index = queue.indexWhere((item) => item.id == mediaId); + await _compatibilitySwitcher.skipToQueueItem(index); + } + + /// Deprecated. Use [AudioHandler.pause] instead. + @Deprecated("Use AudioHandler.pause instead.") + static final pause = _compatibilitySwitcher.pause; + + /// Deprecated. Use [AudioHandler.stop] instead. + @Deprecated("Use AudioHandler.stop instead.") + static final stop = _compatibilitySwitcher.stop; + + /// Deprecated. Use [AudioHandler.seek] instead. + @Deprecated("Use AudioHandler.seek instead.") + static final seekTo = _compatibilitySwitcher.seek; + + /// Deprecated. Use [AudioHandler.skipToNext] instead. + @Deprecated("Use AudioHandler.skipToNext instead.") + static final skipToNext = _compatibilitySwitcher.skipToNext; + + /// Deprecated. Use [AudioHandler.skipToPrevious] instead. + @Deprecated("Use AudioHandler.skipToPrevious instead.") + static final skipToPrevious = _compatibilitySwitcher.skipToPrevious; + + /// Deprecated. Use [AudioHandler.fastForward] instead. + @Deprecated("Use AudioHandler.fastForward instead.") + static final Future Function() fastForward = + _compatibilitySwitcher.fastForward; + + /// Deprecated. Use [AudioHandler.rewind] instead. + @Deprecated("Use AudioHandler.rewind instead.") + static final Future Function() rewind = _compatibilitySwitcher.rewind; + + /// Deprecated. Use [AudioHandler.setRepeatMode] instead. + @Deprecated("Use AudioHandler.setRepeatMode instead.") + static final setRepeatMode = _compatibilitySwitcher.setRepeatMode; + + /// Deprecated. Use [AudioHandler.setShuffleMode] instead. + @Deprecated("Use AudioHandler.setShuffleMode instead.") + static final setShuffleMode = _compatibilitySwitcher.setShuffleMode; + + /// Deprecated. Use [AudioHandler.setRating] instead. + @Deprecated("Use AudioHandler.setRating instead.") + static Future setRating(Rating rating, Map extras) => + _compatibilitySwitcher.setRating(rating, extras.cast()); + + /// Deprecated. Use [AudioHandler.setSpeed] instead. + @Deprecated("Use AudioHandler.setSpeed instead.") + static final setSpeed = _compatibilitySwitcher.setSpeed; + + /// Deprecated. Use [AudioHandler.seekBackward] instead. + @Deprecated("Use audioHandler.seekBackward instead.") + static final seekBackward = _compatibilitySwitcher.seekBackward; + + /// Deprecated. Use [AudioHandler.seekForward] instead. + @Deprecated("Use AudioHandler.seekForward instead.") + static final seekForward = _compatibilitySwitcher.seekForward; + + /// Deprecated. Use [AudioHandler.customAction] instead. + @Deprecated("Use AudioHandler.customAction instead.") + static final Future Function(String, Map) + customAction = _compatibilitySwitcher.customAction; + + /// Deprecated. Use [position] instead. + @Deprecated("Use position instead.") + static final ValueStream positionStream = + BehaviorSubject.seeded(Duration.zero, sync: true) + ..addStream(position) + ..stream; +} + +class _BackgroundAudioHandler extends BaseAudioHandler { + // ignore: deprecated_member_use_from_same_package + late BackgroundAudioTask _task; + + @override + // ignore: deprecated_member_use_from_same_package + Future prepare() => _task.onPrepare(); + + @override + Future prepareFromMediaId(String mediaId, + [Map? extras]) => + // ignore: deprecated_member_use_from_same_package + _task.onPrepareFromMediaId(mediaId); + + @override + // ignore: deprecated_member_use_from_same_package + Future play() => _task.onPlay(); + + @override + Future playFromMediaId(String mediaId, + [Map? extras]) => + // ignore: deprecated_member_use_from_same_package + _task.onPlayFromMediaId(mediaId); + + @override + Future playMediaItem(MediaItem mediaItem) => + // ignore: deprecated_member_use_from_same_package + _task.onPlayMediaItem(mediaItem); + + @override + // ignore: deprecated_member_use_from_same_package + Future pause() => _task.onPause(); + + @override + Future click([MediaButton button = MediaButton.media]) => + // ignore: deprecated_member_use_from_same_package + _task.onClick(button); + + @override + // ignore: deprecated_member_use_from_same_package + Future stop() => _task.onStop(); + + @override + Future addQueueItem(MediaItem mediaItem) => + // ignore: deprecated_member_use_from_same_package + _task.onAddQueueItem(mediaItem); + + @override + Future addQueueItems(List mediaItems) async { + for (var mediaItem in mediaItems) { + // ignore: deprecated_member_use_from_same_package + await _task.onAddQueueItem(mediaItem); + } + } + + @override + Future insertQueueItem(int index, MediaItem mediaItem) => + // ignore: deprecated_member_use_from_same_package + _task.onAddQueueItemAt(mediaItem, index); + + @override + // ignore: deprecated_member_use_from_same_package + Future updateQueue(List queue) => _task.onUpdateQueue(queue); + + @override + Future updateMediaItem(MediaItem mediaItem) => + // ignore: deprecated_member_use_from_same_package + _task.onUpdateMediaItem(mediaItem); + + @override + Future removeQueueItem(MediaItem mediaItem) => + // ignore: deprecated_member_use_from_same_package + _task.onRemoveQueueItem(mediaItem); + + @override + // ignore: deprecated_member_use_from_same_package + Future skipToNext() => _task.onSkipToNext(); + + @override + // ignore: deprecated_member_use_from_same_package + Future skipToPrevious() => _task.onSkipToPrevious(); + + @override + // ignore: deprecated_member_use_from_same_package + Future fastForward() => _task.onFastForward(); + + @override + // ignore: deprecated_member_use_from_same_package + Future rewind() => _task.onRewind(); + + @override + Future skipToQueueItem(int index) async { + final queue = this.queue.nvalue ?? []; + if (index < 0 || index >= queue.length) return; + final mediaItem = queue[index]; + // ignore: deprecated_member_use_from_same_package + await _task.onSkipToQueueItem(mediaItem.id); + } + + @override + // ignore: deprecated_member_use_from_same_package + Future seek(Duration position) => _task.onSeekTo(position); + + @override + Future setRating(Rating rating, [Map? extras]) => + // ignore: deprecated_member_use_from_same_package + _task.onSetRating(rating, extras); + + @override + Future setRepeatMode(AudioServiceRepeatMode repeatMode) => + // ignore: deprecated_member_use_from_same_package + _task.onSetRepeatMode(repeatMode); + + @override + Future setShuffleMode(AudioServiceShuffleMode shuffleMode) => + // ignore: deprecated_member_use_from_same_package + _task.onSetShuffleMode(shuffleMode); + + @override + // ignore: deprecated_member_use_from_same_package + Future seekBackward(bool begin) => _task.onSeekBackward(begin); + + @override + // ignore: deprecated_member_use_from_same_package + Future seekForward(bool begin) => _task.onSeekForward(begin); + + @override + // ignore: deprecated_member_use_from_same_package + Future setSpeed(double speed) => _task.onSetSpeed(speed); + + @override + Future customAction(String name, [Map? extras]) => + // ignore: deprecated_member_use_from_same_package + _task.onCustomAction(name, extras); + + @override + // ignore: deprecated_member_use_from_same_package + Future onNotificationDeleted() => _task.onClose(); + + @override + Future> getChildren(String parentMediaId, + [Map? options]) => + // ignore: deprecated_member_use_from_same_package + _task.onLoadChildren(parentMediaId); +} + +/// This class is deprecated. Use [BaseAudioHandler] instead. +@Deprecated("Use AudioHandler instead.") +abstract class BackgroundAudioTask { + late _BackgroundAudioHandler _handler; + + /// Deprecated. Use [AudioServiceConfig.fastForwardInterval] from [AudioService.config] instead. + @Deprecated( + "Use [AudioServiceConfig.fastForwardInterval] from [AudioService.config] instead.") + Duration get fastForwardInterval => AudioService.config.fastForwardInterval; + + /// Deprecated. Use [AudioServiceConfig.rewindInterval] from [AudioService.config] instead. + @Deprecated( + "Use [AudioServiceConfig.rewindInterval] from [AudioService.config] instead.") + Duration get rewindInterval => AudioService.config.rewindInterval; + + /// Deprecated. The new [AudioHandler] API now automatically starts the + /// service when your implementation enters the playing state. Parameters can + /// be passed via [AudioHandler.customAction]. + @Deprecated("Use AudioService.init instead.") + Future onStart(Map? params) async {} + + /// Deprecated. Replaced by [AudioHandler.stop]. + @mustCallSuper + @Deprecated("Use AudioHandler.stop instead.") + Future onStop() async { + final audioSession = await AudioSession.instance; + try { + await audioSession.setActive(false); + } catch (e) { + // ignore: avoid_print + print("While deactivating audio session: $e"); + } + } + + /// Deprecated. Replaced by [AudioHandler.getChildren]. + @Deprecated("Use AudioHandler.getChildren instead.") + Future> onLoadChildren(String parentMediaId) async => []; + + /// Deprecated. Replaced by [AudioHandler.click]. + @Deprecated("Use AudioHandler.click instead.") + Future onClick(MediaButton? button) async { + switch (button!) { + case MediaButton.media: + if (_handler.playbackState.nvalue!.playing) { + await onPause(); + } else { + await onPlay(); + } + break; + case MediaButton.next: + await onSkipToNext(); + break; + case MediaButton.previous: + await onSkipToPrevious(); + break; + } + } + + /// Deprecated. Replaced by [AudioHandler.pause]. + @Deprecated("Use AudioHandler.pause instead.") + Future onPause() async {} + + /// Deprecated. Replaced by [AudioHandler.prepare]. + @Deprecated("Use AudioHandler.prepare instead.") + Future onPrepare() async {} + + /// Deprecated. Replaced by [AudioHandler.prepareFromMediaId]. + @Deprecated("Use AudioHandler.prepareFromMediaId instead.") + Future onPrepareFromMediaId(String mediaId) async {} + + /// Deprecated. Replaced by [AudioHandler.play]. + @Deprecated("Use AudioHandler.play instead.") + Future onPlay() async {} + + /// Deprecated. Replaced by [AudioHandler.playFromMediaId]. + @Deprecated("Use AudioHandler.playFromMediaId instead.") + Future onPlayFromMediaId(String mediaId) async {} + + /// Deprecated. Replaced by [AudioHandler.playMediaItem]. + @Deprecated("Use AudioHandler.playMediaItem instead.") + Future onPlayMediaItem(MediaItem mediaItem) async {} + + /// Deprecated. Replaced by [AudioHandler.addQueueItem]. + @Deprecated("Use AudioHandler.addQueueItem instead.") + Future onAddQueueItem(MediaItem mediaItem) async {} + + /// Deprecated. Replaced by [AudioHandler.updateQueue]. + @Deprecated("Use AudioHandler.updateQueue instead.") + Future onUpdateQueue(List queue) async {} + + /// Deprecated. Replaced by [AudioHandler.updateMediaItem]. + @Deprecated("Use AudioHandler.updateMediaItem instead.") + Future onUpdateMediaItem(MediaItem mediaItem) async {} + + /// Deprecated. Replaced by [AudioHandler.insertQueueItem]. + @Deprecated("Use AudioHandler.insertQueueItem instead.") + Future onAddQueueItemAt(MediaItem mediaItem, int index) async {} + + /// Deprecated. Replaced by [AudioHandler.removeQueueItem]. + @Deprecated("Use AudioHandler.removeQueueItem instead.") + Future onRemoveQueueItem(MediaItem mediaItem) async {} + + /// Deprecated. Replaced by [AudioHandler.skipToNext]. + @Deprecated("Use AudioHandler.skipToNext instead.") + Future onSkipToNext() => _skip(1); + + /// Deprecated. Replaced by [AudioHandler.skipToPrevious]. + @Deprecated("Use AudioHandler.skipToPrevious instead.") + Future onSkipToPrevious() => _skip(-1); + + /// Deprecated. Replaced by [AudioHandler.fastForward]. + @Deprecated("Use AudioHandler.fastForward instead.") + Future onFastForward() async {} + + /// Deprecated. Replaced by [AudioHandler.rewind]. + @Deprecated("Use AudioHandler.rewind instead.") + Future onRewind() async {} + + /// Deprecated. Replaced by [AudioHandler.skipToQueueItem]. + @Deprecated("Use AudioHandler.skipToQueueItem instead.") + Future onSkipToQueueItem(String mediaId) async {} + + /// Deprecated. Replaced by [AudioHandler.seek]. + @Deprecated("Use AudioHandler.seek instead.") + Future onSeekTo(Duration position) async {} + + /// Deprecated. Replaced by [AudioHandler.setRating]. + @Deprecated("Use AudioHandler.setRating instead.") + Future onSetRating(Rating rating, Map? extras) async {} + + /// Deprecated. Replaced by [AudioHandler.setRepeatMode]. + @Deprecated("Use AudioHandler.setRepeatMode instead.") + Future onSetRepeatMode(AudioServiceRepeatMode repeatMode) async {} + + /// Deprecated. Replaced by [AudioHandler.setShuffleMode]. + @Deprecated("Use AudioHandler.setShuffleMode instead.") + Future onSetShuffleMode(AudioServiceShuffleMode shuffleMode) async {} + + /// Deprecated. Replaced by [AudioHandler.seekBackward]. + @Deprecated("Use AudioHandler.seekBackward instead.") + Future onSeekBackward(bool begin) async {} + + /// Deprecated. Replaced by [AudioHandler.seekForward]. + @Deprecated("Use AudioHandler.seekForward instead.") + Future onSeekForward(bool begin) async {} + + /// Deprecated. Replaced by [AudioHandler.setSpeed]. + @Deprecated("Use AudioHandler.setSpeed instead.") + Future onSetSpeed(double speed) async {} + + /// Deprecated. Replaced by [AudioHandler.customAction]. + @Deprecated("Use AudioHandler.customAction instead.") + Future onCustomAction(String name, dynamic arguments) async {} + + /// Deprecated. Replaced by [AudioHandler.onTaskRemoved]. + @Deprecated("Use AudioHandler.onTaskRemoved instead.") + Future onTaskRemoved() async {} + + /// Deprecated. Replaced by [AudioHandler.onNotificationDeleted]. + @Deprecated("Use AudioHandler.onNotificationDeleted instead.") + Future onClose() => onStop(); + + Future _skip(int offset) async { + final mediaItem = _handler.mediaItem.nvalue; + if (mediaItem == null) return; + final queue = _handler.queue.nvalue ?? []; + final i = queue.indexOf(mediaItem); + if (i == -1) return; + final newIndex = i + offset; + if (newIndex >= 0 && newIndex < queue.length) { + await onSkipToQueueItem(queue[newIndex].id); + } + } +} + +/// An [AudioHandler] plays audio, provides state updates and query results to +/// clients. It implements standard protocols that allow it to be remotely +/// controlled by the lock screen, media notifications, the iOS control center, +/// headsets, smart watches, car audio systems, and other compatible agents. +/// +/// This class cannot be subclassed directly. Implementations should subclass +/// [BaseAudioHandler], and composite behaviours should be defined as subclasses +/// of [CompositeAudioHandler]. +abstract class AudioHandler { + AudioHandler._(); + + /// Prepare media items for playback. + Future prepare(); + + /// Prepare a specific media item for playback. + Future prepareFromMediaId(String mediaId, + [Map? extras]); + + /// Prepare playback from a search query. + Future prepareFromSearch(String query, [Map? extras]); + + /// Prepare a media item represented by a Uri for playback. + Future prepareFromUri(Uri uri, [Map? extras]); + + /// Start or resume playback. + Future play(); + + /// Play a specific media item. + Future playFromMediaId(String mediaId, [Map? extras]); + + /// Begin playback from a search query. + Future playFromSearch(String query, [Map? extras]); + + /// Play a media item represented by a Uri. + Future playFromUri(Uri uri, [Map? extras]); + + /// Play a specific media item. + Future playMediaItem(MediaItem mediaItem); + + /// Pause playback. + Future pause(); + + /// Process a headset button click, where [button] defaults to + /// [MediaButton.media]. + Future click([MediaButton button = MediaButton.media]); + + /// Stop playback and release resources. + Future stop(); + + /// Add [mediaItem] to the queue. + Future addQueueItem(MediaItem mediaItem); + + /// Add [mediaItems] to the queue. + Future addQueueItems(List mediaItems); + + /// Insert [mediaItem] into the queue at position [index]. + Future insertQueueItem(int index, MediaItem mediaItem); + + /// Update to the queue to [queue]. + Future updateQueue(List queue); + + /// Update the properties of [mediaItem]. + Future updateMediaItem(MediaItem mediaItem); + + /// Remove [mediaItem] from the queue. + Future removeQueueItem(MediaItem mediaItem); + + /// Remove media item from the queue at the specified [index]. + Future removeQueueItemAt(int index); + + /// Skip to the next item in the queue. + Future skipToNext(); + + /// Skip to the previous item in the queue. + Future skipToPrevious(); + + /// Jump forward by [AudioServiceConfig.fastForwardInterval]. + Future fastForward(); + + /// Jump backward by [AudioServiceConfig.rewindInterval]. Note: this value + /// must be positive. + Future rewind(); + + /// Skip to a queue item. + Future skipToQueueItem(int index); + + /// Seek to [position]. + Future seek(Duration position); + + /// Set the rating. + Future setRating(Rating rating, [Map? extras]); + + /// Set whether captioning is enabled. + Future setCaptioningEnabled(bool enabled); + + /// Set the repeat mode. + Future setRepeatMode(AudioServiceRepeatMode repeatMode); + + /// Set the shuffle mode. + Future setShuffleMode(AudioServiceShuffleMode shuffleMode); + + /// Begin or end seeking backward continuously. + Future seekBackward(bool begin); + + /// Begin or end seeking forward continuously. + Future seekForward(bool begin); + + /// Set the playback speed. + Future setSpeed(double speed); + + /// A mechanism to support app-specific actions. + Future customAction(String name, [Map? extras]); + + /// Handle the task being swiped away in the task manager (Android). + Future onTaskRemoved(); + + /// Handle the notification being swiped away (Android). + Future onNotificationDeleted(); + + /// Get the children of a parent media item. + Future> getChildren(String parentMediaId, + [Map? options]); + + /// Get a value stream that emits service-specific options to send to the + /// client whenever the children under the specified parent change. The + /// emitted options may contain information about what changed. A client that + /// is subscribed to this stream should call [getChildren] to obtain the + /// changed children. + ValueStream> subscribeToChildren(String parentMediaId); + + /// Get a particular media item. + Future getMediaItem(String mediaId); + + /// Search for media items. + Future> search(String query, [Map? extras]); + + /// Set the remote volume on Android. This works only when using + /// [RemoteAndroidPlaybackInfo]. + Future androidSetRemoteVolume(int volumeIndex); + + /// Adjust the remote volume on Android. This works only when using + /// [RemoteAndroidPlaybackInfo]. + Future androidAdjustRemoteVolume(AndroidVolumeDirection direction); + + /// A value stream of playback states. + ValueStream get playbackState; + + /// A value stream of the current queue. + ValueStream> get queue; + + /// A value stream of the current queueTitle. + ValueStream get queueTitle; + + /// A value stream of the current media item. + ValueStream get mediaItem; + + /// A value stream of the current rating style. + ValueStream get ratingStyle; + + /// A value stream of the current [AndroidPlaybackInfo]. + ValueStream get androidPlaybackInfo; + + /// A stream of custom events. + Stream get customEvent; + + /// A stream of custom states. + ValueStream get customState; +} + +/// A [SwitchAudioHandler] wraps another [AudioHandler] that may be switched for +/// another at any time by setting [inner]. +class SwitchAudioHandler extends CompositeAudioHandler { + final BehaviorSubject _playbackState = BehaviorSubject(); + final BehaviorSubject> _queue = BehaviorSubject(); + final BehaviorSubject _queueTitle = BehaviorSubject(); + final BehaviorSubject _mediaItem = BehaviorSubject(); + final BehaviorSubject _androidPlaybackInfo = + BehaviorSubject(); + final BehaviorSubject _ratingStyle = BehaviorSubject(); + final PublishSubject _customEvent = PublishSubject(); + final BehaviorSubject _customState = BehaviorSubject(); + + @override + ValueStream get playbackState => _playbackState; + + @override + ValueStream> get queue => _queue; + + @override + ValueStream get queueTitle => _queueTitle; + + @override + ValueStream get mediaItem => _mediaItem; + + @override + ValueStream get androidPlaybackInfo => + _androidPlaybackInfo; + + @override + ValueStream get ratingStyle => _ratingStyle; + + @override + Stream get customEvent => _customEvent; + + @override + ValueStream get customState => _customState; + + StreamSubscription? _playbackStateSubscription; + StreamSubscription>? _queueSubscription; + StreamSubscription? _queueTitleSubscription; + StreamSubscription? _mediaItemSubscription; + StreamSubscription? _androidPlaybackInfoSubscription; + StreamSubscription? _ratingStyleSubscription; + StreamSubscription? _customEventSubscription; + StreamSubscription? _customStateSubscription; + + /// Creates a [SwitchAudioHandler] with an initial [inner] handler, which + /// defaults to a no-op handler. + SwitchAudioHandler([AudioHandler? inner]) + : this._(inner ?? BaseAudioHandler()); + + SwitchAudioHandler._(AudioHandler inner) : super(inner) { + this.inner = inner; + } + + /// The current inner [AudioHandler] that this [SwitchAudioHandler] will + /// delegate to. + AudioHandler get inner => _inner; + + set inner(AudioHandler newInner) { + // Should disallow all ancestors... + assert(newInner != this); + _playbackStateSubscription?.cancel(); + _queueSubscription?.cancel(); + _queueTitleSubscription?.cancel(); + _mediaItemSubscription?.cancel(); + _androidPlaybackInfoSubscription?.cancel(); + _ratingStyleSubscription?.cancel(); + _customEventSubscription?.cancel(); + _customStateSubscription?.cancel(); + _inner = newInner; + _playbackStateSubscription = inner.playbackState.listen(_playbackState.add); + _queueSubscription = inner.queue.listen(_queue.add); + _queueTitleSubscription = inner.queueTitle.listen(_queueTitle.add); + // XXX: This only works in one direction. + _mediaItemSubscription = inner.mediaItem.listen(_mediaItem.add); + _androidPlaybackInfoSubscription = + inner.androidPlaybackInfo.listen(_androidPlaybackInfo.add); + _ratingStyleSubscription = inner.ratingStyle.listen(_ratingStyle.add); + _customEventSubscription = inner.customEvent.listen(_customEvent.add); + _customStateSubscription = inner.customState.listen(_customState.add); + } +} + +/// A [CompositeAudioHandler] wraps another [AudioHandler] and adds additional +/// behaviour to it. Each method will by default pass through to the +/// corresponding method of the wrapped handler. If you override a method, it +/// must call super in addition to any "additional" functionality you add. +class CompositeAudioHandler extends AudioHandler { + AudioHandler _inner; + + /// Create the [CompositeAudioHandler] with the given wrapped handler. + CompositeAudioHandler(AudioHandler inner) + : _inner = inner, + super._(); + + @override + @mustCallSuper + Future prepare() => _inner.prepare(); + + @override + @mustCallSuper + Future prepareFromMediaId(String mediaId, + [Map? extras]) => + _inner.prepareFromMediaId(mediaId, extras); + + @override + @mustCallSuper + Future prepareFromSearch(String query, + [Map? extras]) => + _inner.prepareFromSearch(query, extras); + + @override + @mustCallSuper + Future prepareFromUri(Uri uri, [Map? extras]) => + _inner.prepareFromUri(uri, extras); + + @override + @mustCallSuper + Future play() => _inner.play(); + + @override + @mustCallSuper + Future playFromMediaId(String mediaId, + [Map? extras]) => + _inner.playFromMediaId(mediaId, extras); + + @override + @mustCallSuper + Future playFromSearch(String query, [Map? extras]) => + _inner.playFromSearch(query, extras); + + @override + @mustCallSuper + Future playFromUri(Uri uri, [Map? extras]) => + _inner.playFromUri(uri, extras); + + @override + @mustCallSuper + Future playMediaItem(MediaItem mediaItem) => + _inner.playMediaItem(mediaItem); + + @override + @mustCallSuper + Future pause() => _inner.pause(); + + @override + @mustCallSuper + Future click([MediaButton button = MediaButton.media]) => + _inner.click(button); + + @override + @mustCallSuper + Future stop() => _inner.stop(); + + @override + @mustCallSuper + Future addQueueItem(MediaItem mediaItem) => + _inner.addQueueItem(mediaItem); + + @override + @mustCallSuper + Future addQueueItems(List mediaItems) => + _inner.addQueueItems(mediaItems); + + @override + @mustCallSuper + Future insertQueueItem(int index, MediaItem mediaItem) => + _inner.insertQueueItem(index, mediaItem); + + @override + @mustCallSuper + Future updateQueue(List queue) => _inner.updateQueue(queue); + + @override + @mustCallSuper + Future updateMediaItem(MediaItem mediaItem) => + _inner.updateMediaItem(mediaItem); + + @override + @mustCallSuper + Future removeQueueItem(MediaItem mediaItem) => + _inner.removeQueueItem(mediaItem); + + @override + @mustCallSuper + Future removeQueueItemAt(int index) => _inner.removeQueueItemAt(index); + + @override + @mustCallSuper + Future skipToNext() => _inner.skipToNext(); + + @override + @mustCallSuper + Future skipToPrevious() => _inner.skipToPrevious(); + + @override + @mustCallSuper + Future fastForward() => _inner.fastForward(); + + @override + @mustCallSuper + Future rewind() => _inner.rewind(); + + @override + @mustCallSuper + Future skipToQueueItem(int index) => _inner.skipToQueueItem(index); + + @override + @mustCallSuper + Future seek(Duration position) => _inner.seek(position); + + @override + @mustCallSuper + Future setRating(Rating rating, [Map? extras]) => + _inner.setRating(rating, extras); + + @override + @mustCallSuper + Future setCaptioningEnabled(bool enabled) => + _inner.setCaptioningEnabled(enabled); + + @override + @mustCallSuper + Future setRepeatMode(AudioServiceRepeatMode repeatMode) => + _inner.setRepeatMode(repeatMode); + + @override + @mustCallSuper + Future setShuffleMode(AudioServiceShuffleMode shuffleMode) => + _inner.setShuffleMode(shuffleMode); + + @override + @mustCallSuper + Future seekBackward(bool begin) => _inner.seekBackward(begin); + + @override + @mustCallSuper + Future seekForward(bool begin) => _inner.seekForward(begin); + + @override + @mustCallSuper + Future setSpeed(double speed) => _inner.setSpeed(speed); + + @override + @mustCallSuper + Future customAction(String name, [Map? extras]) => + _inner.customAction(name, extras); + + @override + @mustCallSuper + Future onTaskRemoved() => _inner.onTaskRemoved(); + + @override + @mustCallSuper + Future onNotificationDeleted() => _inner.onNotificationDeleted(); + + @override + @mustCallSuper + Future> getChildren(String parentMediaId, + [Map? options]) => + _inner.getChildren(parentMediaId, options); + + @override + @mustCallSuper + ValueStream> subscribeToChildren(String parentMediaId) => + _inner.subscribeToChildren(parentMediaId); + + @override + @mustCallSuper + Future getMediaItem(String mediaId) => + _inner.getMediaItem(mediaId); + + @override + @mustCallSuper + Future> search(String query, + [Map? extras]) => + _inner.search(query, extras); + + @override + @mustCallSuper + Future androidSetRemoteVolume(int volumeIndex) => + _inner.androidSetRemoteVolume(volumeIndex); + + @override + @mustCallSuper + Future androidAdjustRemoteVolume(AndroidVolumeDirection direction) => + _inner.androidAdjustRemoteVolume(direction); + + @override + ValueStream get playbackState => _inner.playbackState; + + @override + ValueStream> get queue => _inner.queue; + + @override + ValueStream get queueTitle => _inner.queueTitle; + + @override + ValueStream get mediaItem => _inner.mediaItem; + + @override + ValueStream get ratingStyle => _inner.ratingStyle; + + @override + ValueStream get androidPlaybackInfo => + _inner.androidPlaybackInfo; + + @override + Stream get customEvent => _inner.customEvent; + + @override + ValueStream get customState => _inner.customState; +} + +class _IsolateRequest { + /// The send port for sending the response of this request. + final SendPort sendPort; + final String method; + final List? arguments; + + _IsolateRequest(this.sendPort, this.method, [this.arguments]); +} + +/// A [CompositeAudioHandler] that can be accessed from other isolates via +/// [lookup]. +/// +/// This handler recognises the custom action 'unregister' so that +/// `customAction('unregister')` can be called from a client to unregister this +/// handler, or it can be unregistered by a direct invocation of [unregister]. +class IsolatedAudioHandler extends CompositeAudioHandler { + /// The default port name by which this isolated audio handler can be looked + /// up. + static const defaultPortName = 'com.ryanheise.audioservice.port'; + + /// Connect to an [IsolatedAudioHandler] from another isolate having the name + /// [portName] (defaulting to [defaultPortName]). + static Future lookup( + {String portName = defaultPortName}) async { + assert(!kIsWeb, "Isolates are not supported on web"); + final handler = _ClientIsolatedAudioHandler(portName: portName); + await handler._init(); + return handler; + } + + /// The port name to use when looking up this handler. + final String portName; + + final _receivePort = ReceivePort(); + + /// Creates an [IsolatedAudioHandler] that can be looked up by [portName] + /// (defaulting to [defaultPortName]). + /// + /// This will throw a [StateError] if another [IsolatedAudioHandler] was + /// already registered with the given port name. Setting [overridePortName] to + /// `true` will unregister any existing port name first. However, this is + /// inherently racy and may still throw the same [StateError] if another + /// isolate is able to register another new handler with the same name before + /// this isolate can. + IsolatedAudioHandler( + super.inner, { + this.portName = defaultPortName, + bool overridePortName = false, + }) : assert(!kIsWeb) { + _receivePort.listen((dynamic event) async { + final request = event as _IsolateRequest; + switch (request.method) { + case 'playbackState': + _syncStream(playbackState, request.arguments![0] as SendPort); + request.sendPort.send(null); + break; + case 'queue': + _syncStream(queue, request.arguments![0] as SendPort); + request.sendPort.send(null); + break; + case 'queueTitle': + _syncStream(queueTitle, request.arguments![0] as SendPort); + request.sendPort.send(null); + break; + case 'mediaItem': + _syncStream(mediaItem, request.arguments![0] as SendPort); + request.sendPort.send(null); + break; + case 'androidPlaybackInfo': + _syncStream(androidPlaybackInfo, request.arguments![0] as SendPort); + request.sendPort.send(null); + break; + case 'ratingStyle': + _syncStream(ratingStyle, request.arguments![0] as SendPort); + request.sendPort.send(null); + break; + case 'customEvent': + _syncStream(customEvent, request.arguments![0] as SendPort); + request.sendPort.send(null); + break; + case 'customState': + _syncStream(customState, request.arguments![0] as SendPort); + request.sendPort.send(null); + break; + case 'prepare': + await prepare(); + request.sendPort.send(null); + break; + case 'prepareFromMediaId': + await prepareFromMediaId( + request.arguments![0] as String, + request.arguments![1] as Map?, + ); + request.sendPort.send(null); + break; + case 'prepareFromSearch': + await prepareFromSearch( + request.arguments![0] as String, + request.arguments![1] as Map?, + ); + request.sendPort.send(null); + break; + case 'prepareFromUri': + await prepareFromUri( + request.arguments![0] as Uri, + request.arguments![1] as Map?, + ); + request.sendPort.send(null); + break; + case 'play': + await play(); + request.sendPort.send(null); + break; + case 'playFromMediaId': + await playFromMediaId( + request.arguments![0] as String, + request.arguments![1] as Map?, + ); + request.sendPort.send(null); + break; + case 'playFromSearch': + await playFromSearch( + request.arguments![0] as String, + request.arguments![1] as Map?, + ); + request.sendPort.send(null); + break; + case 'playFromUri': + await playFromUri( + request.arguments![0] as Uri, + request.arguments![1] as Map?, + ); + request.sendPort.send(null); + break; + case 'playMediaItem': + await playMediaItem(request.arguments![0] as MediaItem); + request.sendPort.send(null); + break; + case 'pause': + await pause(); + request.sendPort.send(null); + break; + case 'click': + await click(request.arguments![0] as MediaButton); + request.sendPort.send(null); + break; + case 'stop': + await stop(); + request.sendPort.send(null); + break; + case 'addQueueItem': + await addQueueItem(request.arguments![0] as MediaItem); + request.sendPort.send(null); + break; + case 'addQueueItems': + await addQueueItems(request.arguments![0] as List); + request.sendPort.send(null); + break; + case 'insertQueueItem': + await insertQueueItem( + request.arguments![0] as int, + request.arguments![1] as MediaItem, + ); + request.sendPort.send(null); + break; + case 'updateQueue': + await updateQueue(request.arguments![0] as List); + request.sendPort.send(null); + break; + case 'updateMediaItem': + await updateMediaItem(request.arguments![0] as MediaItem); + request.sendPort.send(null); + break; + case 'removeQueueItem': + await removeQueueItem(request.arguments![0] as MediaItem); + request.sendPort.send(null); + break; + case 'removeQueueItemAt': + await removeQueueItemAt(request.arguments![0] as int); + request.sendPort.send(null); + break; + case 'skipToNext': + await skipToNext(); + request.sendPort.send(null); + break; + case 'skipToPrevious': + await skipToPrevious(); + request.sendPort.send(null); + break; + case 'fastForward': + await fastForward(); + request.sendPort.send(null); + break; + case 'rewind': + await rewind(); + request.sendPort.send(null); + break; + case 'skipToQueueItem': + await skipToQueueItem(request.arguments![0] as int); + request.sendPort.send(null); + break; + case 'seek': + await seek(request.arguments![0] as Duration); + request.sendPort.send(null); + break; + case 'setRating': + await setRating( + request.arguments![0] as Rating, + request.arguments![1] as Map?, + ); + request.sendPort.send(null); + break; + case 'setCaptioningEnabled': + await setCaptioningEnabled(request.arguments![0] as bool); + request.sendPort.send(null); + break; + case 'setRepeatMode': + await setRepeatMode(request.arguments![0] as AudioServiceRepeatMode); + request.sendPort.send(null); + break; + case 'setShuffleMode': + await setShuffleMode( + request.arguments![0] as AudioServiceShuffleMode); + request.sendPort.send(null); + break; + case 'seekBackward': + await seekBackward(request.arguments![0] as bool); + request.sendPort.send(null); + break; + case 'seekForward': + await seekForward(request.arguments![0] as bool); + request.sendPort.send(null); + break; + case 'setSpeed': + await setSpeed(request.arguments![0] as double); + request.sendPort.send(null); + break; + case 'customAction': + request.sendPort.send(await customAction( + request.arguments![0] as String, + request.arguments![1] as Map?, + )); + break; + case 'onTaskRemoved': + await onTaskRemoved(); + request.sendPort.send(null); + break; + case 'onNotificationDeleted': + await onNotificationDeleted(); + request.sendPort.send(null); + break; + case 'getChildren': + request.sendPort.send(await getChildren( + request.arguments![0] as String, + request.arguments![1] as Map?, + )); + break; + case 'subscribeToChildren': + final parentMediaId = request.arguments![0] as String; + final sendPort = request.arguments![1] as SendPort?; + subscribeToChildren(parentMediaId).listen(sendPort!.send); + break; + case 'getMediaItem': + final result = await getMediaItem(request.arguments![0] as String); + request.sendPort.send(result); + break; + case 'search': + request.sendPort.send(await search( + request.arguments![0] as String, + request.arguments![1] as Map?, + )); + break; + case 'androidAdjustRemoteVolume': + await androidAdjustRemoteVolume( + request.arguments![0] as AndroidVolumeDirection); + request.sendPort.send(null); + break; + case 'androidSetRemoteVolume': + await androidSetRemoteVolume(request.arguments![0] as int); + request.sendPort.send(null); + break; + } + }); + if (overridePortName) { + IsolateNameServer.removePortNameMapping(portName); + } + final success = + IsolateNameServer.registerPortWithName(_receivePort.sendPort, portName); + if (!success) { + throw StateError( + 'Port name $portName is already registered by another IsolatedAudioHandler.'); + } + } + + /// Unregisters this handler so that it can no longer be looked up by + /// [portName]. + void unregister() { + IsolateNameServer.removePortNameMapping(portName); + } + + /// Forwards events from `stream` to the requesting isolate via [sendPort]. + void _syncStream(Stream stream, SendPort sendPort) { + stream.listen(sendPort.send); + } + + @override + Future customAction(String name, + [Map? extras]) async { + if (name == 'unregister') { + unregister(); + } else { + return super.customAction(name, extras); + } + } +} + +/// A proxy for an [IsolatedAudioHandler] running in another isolate. +/// +/// All method invocations on this handler will be forwarded to the other +/// handler, and all stream events emitted by the other handler can be listened +/// to on this handler. +class _ClientIsolatedAudioHandler implements BaseAudioHandler { + final _childrenSubjects = >>{}; + + /// The port name of the [IsolatedAudioHandler] that this client handler + /// connects to. + final String portName; + + @override + final BehaviorSubject playbackState = BehaviorSubject(); + + @override + final BehaviorSubject> queue = BehaviorSubject(); + + @override + final BehaviorSubject queueTitle = BehaviorSubject(); + + @override + final BehaviorSubject mediaItem = BehaviorSubject(); + + @override + final BehaviorSubject androidPlaybackInfo = + BehaviorSubject(); + + @override + final BehaviorSubject ratingStyle = BehaviorSubject(); + + @override + final PublishSubject customEvent = PublishSubject(); + + @override + final BehaviorSubject customState = BehaviorSubject(); + + _ClientIsolatedAudioHandler({ + this.portName = IsolatedAudioHandler.defaultPortName, + }); + + Future _init() async { + await _syncSubject(playbackState, 'playbackState'); + await _syncSubject(queue, 'queue'); + await _syncSubject(queueTitle, 'queueTitle'); + await _syncSubject(mediaItem, 'mediaItem'); + await _syncSubject(androidPlaybackInfo, 'androidPlaybackInfo'); + await _syncSubject(ratingStyle, 'ratingStyle'); + await _syncSubject(customEvent, 'customEvent'); + await _syncSubject(customState, 'customState'); + } + + /// Opens a channel to the [IsolatedAudioSource] through which this proxy can + /// listen to events on a stream named [name] from that [IsolatedAudioSource] + /// and forward them on to this proxy's corresponding stream subject to + /// deliver to the client isolate. + Future _syncSubject(Subject subject, String name) async { + final receivePort = ReceivePort(); + receivePort.cast().listen(subject.add); + await _send(name, [receivePort.sendPort]); + } + + @override + Future prepare() => _send('prepare'); + + @override + Future prepareFromMediaId(String mediaId, + [Map? extras]) => + _send('prepareFromMediaId', [mediaId, extras]); + + @override + Future prepareFromSearch(String query, + [Map? extras]) => + _send('prepareFromSearch', [query, extras]); + + @override + Future prepareFromUri(Uri uri, [Map? extras]) => + _send('prepareFromUri', [uri, extras]); + + @override + Future play() => _send('play'); + + @override + Future playFromMediaId(String mediaId, + [Map? extras]) => + _send('playFromMediaId', [mediaId, extras]); + + @override + Future playFromSearch(String query, [Map? extras]) => + _send('playFromSearch', [query, extras]); + + @override + Future playFromUri(Uri uri, [Map? extras]) => + _send('playFromUri', [uri, extras]); + + @override + Future playMediaItem(MediaItem mediaItem) => + _send('playMediaItem', [mediaItem]); + + @override + Future pause() => _send('pause'); + + @override + Future click([MediaButton button = MediaButton.media]) => + _send('click', [button]); + + @override + Future stop() => _send('stop'); + + @override + Future addQueueItem(MediaItem mediaItem) => + _send('addQueueItem', [mediaItem]); + + @override + Future addQueueItems(List mediaItems) => + _send('addQueueItems', [mediaItems]); + + @override + Future insertQueueItem(int index, MediaItem mediaItem) => + _send('insertQueueItem', [index, mediaItem]); + + @override + Future updateQueue(List queue) => + _send('updateQueue', [queue]); + + @override + Future updateMediaItem(MediaItem mediaItem) => + _send('updateMediaItem', [mediaItem]); + + @override + Future removeQueueItem(MediaItem mediaItem) => + _send('removeQueueItem', [mediaItem]); + + @override + Future removeQueueItemAt(int index) => + _send('removeQueueItemAt', [index]); + + @override + Future skipToNext() => _send('skipToNext'); + + @override + Future skipToPrevious() => _send('skipToPrevious'); + + @override + Future fastForward() => _send('fastForward'); + + @override + Future rewind() => _send('rewind'); + + @override + Future skipToQueueItem(int index) => + _send('skipToQueueItem', [index]); + + @override + Future seek(Duration position) => _send('seek', [position]); + + @override + Future setRating(Rating rating, [Map? extras]) => + _send('setRating', [rating, extras]); + + @override + Future setCaptioningEnabled(bool enabled) => + _send('setCaptioningEnabled', [enabled]); + + @override + Future setRepeatMode(AudioServiceRepeatMode repeatMode) => + _send('setRepeatMode', [repeatMode]); + + @override + Future setShuffleMode(AudioServiceShuffleMode shuffleMode) => + _send('setShuffleMode', [shuffleMode]); + + @override + Future seekBackward(bool begin) => + _send('seekBackward', [begin]); + + @override + Future seekForward(bool begin) => + _send('seekForward', [begin]); + + @override + Future setSpeed(double speed) => _send('setSpeed', [speed]); + + @override + Future customAction(String name, [Map? extras]) => + _send('customAction', [name, extras]); + + @override + Future onTaskRemoved() => _send('onTaskRemoved'); + + @override + Future onNotificationDeleted() => _send('onNotificationDeleted'); + + @override + Future> getChildren(String parentMediaId, + [Map? options]) async => + (await _send('getChildren', [parentMediaId, options])) + as List; + + @override + ValueStream> subscribeToChildren(String parentMediaId) { + var childrenSubject = _childrenSubjects[parentMediaId]; + if (childrenSubject == null) { + childrenSubject = _childrenSubjects[parentMediaId] = BehaviorSubject(); + final receivePort = ReceivePort(); + receivePort.listen((dynamic options) { + childrenSubject!.add(options as Map); + }); + _send('subscribeToChildren', + [parentMediaId, receivePort.sendPort]); + } + return childrenSubject; + } + + @override + Future getMediaItem(String mediaId) async => + (await _send('getMediaItem', [mediaId])) as MediaItem?; + + @override + Future> search(String query, + [Map? extras]) async => + (await _send('search', [query, extras])) as List; + + @override + Future androidAdjustRemoteVolume(AndroidVolumeDirection direction) => + _send('androidAdjustRemoteVolume', [direction]); + + @override + Future androidSetRemoteVolume(int volumeIndex) => + _send('androidSetRemoteVolume', [volumeIndex]); + + Future _send(String method, [List? arguments]) async { + final sendPort = IsolateNameServer.lookupPortByName(portName); + if (sendPort == null) { + throw StateError('IsolatedAudioHandler $portName not available'); + } + final receivePort = ReceivePort(); + sendPort.send(_IsolateRequest(receivePort.sendPort, method, arguments)); + final dynamic result = await receivePort.first; + receivePort.close(); + return result; + } +} + +/// Base class for implementations of [AudioHandler]. It provides default +/// implementations of all methods and streams. Each stream in this class is +/// specialized as either a [BehaviorSubject] or [PublishSubject] providing an +/// additional `add` method for emitting values on those streams. +/// +/// These are [BehaviorSubject]s provided by this class: +/// +/// * [playbackState] +/// * [queue] +/// * [queueTitle] +/// * [androidPlaybackInfo] +/// * [ratingStyle] +/// +/// Besides them, there's also [customEvent] which is a [PublishSubject]. +/// +/// You can choose to implement all methods yourself, or you may leverage some +/// mixins to provide default implementations of certain behaviours: +/// +/// * [QueueHandler] provides default implementations of methods for updating +/// and navigating the queue. +/// * [SeekHandler] provides default implementations of methods for seeking +/// forwards and backwards. +/// +/// ## Android service lifecycle and state transitions +/// +/// On Android, the [AudioHandler] runs inside an Android service. This allows +/// the audio logic to continue running in the background, and also an app that +/// had previously been terminated to wake up and resume playing audio when the +/// user click on the play button in a media notification or headset. +/// +/// ### Foreground/background transitions +/// +/// The underlying Android service enters the `foreground` state whenever +/// [PlaybackState.playing] becomes `true`, and enters the `background` state +/// whenever [PlaybackState.playing] becomes `false`. +/// +/// ### Start/stop transitions +/// +/// The underlying Android service enters the `started` state whenever +/// [PlaybackState.playing] becomes `true`, and enters the `stopped` state +/// whenever [PlaybackState.processingState] becomes `idle`. +/// +/// ### Create/destroy lifecycle +/// +/// The underlying service is created either when a client binds to it, or when +/// it is started, and it is destroyed when no clients are bound to it AND it is +/// stopped. When the Flutter UI is attached to an Android Activity, this will +/// also bind to the service, and it will unbind from the service when the +/// Activity is destroyed. A media notification will also bind to the service. +/// +/// If the service needs to be created when the app is not already running, your +/// app's `main` entrypoint will be called in the background which should +/// initialise your [AudioHandler]. +class BaseAudioHandler extends AudioHandler { + /// A controller for broadcasting the current [PlaybackState] to the app's UI, + /// media notification and other clients. Example usage: + /// + /// ```dart + /// playbackState.add(playbackState.value!.copyWith(playing: true)); + /// ``` + /// + /// The state changes broadcast via this stream can be listened to via the + /// Flutter app's UI + @override + final BehaviorSubject playbackState = + BehaviorSubject.seeded(PlaybackState()); + + /// A controller for broadcasting the current queue to the app's UI, media + /// notification and other clients. Example usage: + /// + /// ```dart + /// queue.add(queue.value! + [additionalItem]); + /// ``` + @override + final BehaviorSubject> queue = + BehaviorSubject.seeded([]); + + /// A controller for broadcasting the current queue title to the app's UI, media + /// notification and other clients. Example usage: + /// + /// ```dart + /// queueTitle.add(newTitle); + /// ``` + @override + final BehaviorSubject queueTitle = BehaviorSubject.seeded(''); + + /// A controller for broadcasting the current media item to the app's UI, + /// media notification and other clients. Example usage: + /// + /// ```dart + /// mediaItem.add(item); + /// ``` + @override + final BehaviorSubject mediaItem = BehaviorSubject.seeded(null); + + /// A controller for broadcasting the current [AndroidPlaybackInfo] to the app's UI, + /// media notification and other clients. Example usage: + /// + /// ```dart + /// androidPlaybackInfo.add(newPlaybackInfo); + /// ``` + @override + final BehaviorSubject androidPlaybackInfo = + BehaviorSubject(); + + /// A controller for broadcasting the current rating style to the app's UI, + /// media notification and other clients. Example usage: + /// + /// ```dart + /// ratingStyle.add(style); + /// ``` + @override + final BehaviorSubject ratingStyle = BehaviorSubject(); + + /// A controller for broadcasting a custom event to the app's UI. + /// A shorthand for the event stream is [customEvent]. + /// Example usage: + /// + /// ```dart + /// customEventSubject.add(MyCustomEvent(arg: 3)); + /// ``` + @override + final PublishSubject customEvent = PublishSubject(); + + /// A controller for broadcasting the current custom state to the app's UI. + /// Example usage: + /// + /// ```dart + /// customState.add(MyCustomState(...)); + /// ``` + @override + final BehaviorSubject customState = BehaviorSubject(); + + /// Constructor. Normally this is called from subclasses via `super`. + BaseAudioHandler() : super._(); + + @override + Future prepare() async {} + + @override + Future prepareFromMediaId(String mediaId, + [Map? extras]) async {} + + @override + Future prepareFromSearch(String query, + [Map? extras]) async {} + + @override + Future prepareFromUri(Uri uri, [Map? extras]) async {} + + @override + Future play() async {} + + @override + Future playFromMediaId(String mediaId, + [Map? extras]) async {} + + @override + Future playFromSearch(String query, + [Map? extras]) async {} + + @override + Future playFromUri(Uri uri, [Map? extras]) async {} + + @override + Future playMediaItem(MediaItem mediaItem) async {} + + @override + Future pause() async {} + + @override + Future click([MediaButton button = MediaButton.media]) async { + switch (button) { + case MediaButton.media: + if (playbackState.nvalue?.playing == true) { + await pause(); + } else { + await play(); + } + break; + case MediaButton.next: + await skipToNext(); + break; + case MediaButton.previous: + await skipToPrevious(); + break; + } + } + + /// Stop playback and release resources. + /// + /// The default implementation (which may be overridden) updates + /// [playbackState] by setting the processing state to + /// [AudioProcessingState.idle] which disables the system notification. + @override + Future stop() async { + playbackState.add(playbackState.nvalue! + .copyWith(processingState: AudioProcessingState.idle)); + await playbackState.firstWhere( + (state) => state.processingState == AudioProcessingState.idle); + } + + @override + Future addQueueItem(MediaItem mediaItem) async {} + + @override + Future addQueueItems(List mediaItems) async {} + + @override + Future insertQueueItem(int index, MediaItem mediaItem) async {} + + @override + Future updateQueue(List queue) async {} + + @override + Future updateMediaItem(MediaItem mediaItem) async {} + + @override + Future removeQueueItem(MediaItem mediaItem) async {} + + @override + Future removeQueueItemAt(int index) async {} + + @override + Future skipToNext() async {} + + @override + Future skipToPrevious() async {} + + @override + Future fastForward() async {} + + @override + Future rewind() async {} + + @override + Future skipToQueueItem(int index) async {} + + @override + Future seek(Duration position) async {} + + @override + Future setRating(Rating rating, [Map? extras]) async {} + + @override + Future setCaptioningEnabled(bool enabled) async {} + + @override + Future setRepeatMode(AudioServiceRepeatMode repeatMode) async {} + + @override + Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async {} + + @override + Future seekBackward(bool begin) async {} + + @override + Future seekForward(bool begin) async {} + + @override + Future setSpeed(double speed) async {} + + @override + Future customAction(String name, + [Map? extras]) async {} + + @override + Future onTaskRemoved() async {} + + @override + Future onNotificationDeleted() async { + await stop(); + } + + @override + Future> getChildren(String parentMediaId, + [Map? options]) async => + []; + + @override + ValueStream> subscribeToChildren(String parentMediaId) => + BehaviorSubject.seeded({}); + + @override + Future getMediaItem(String mediaId) async => null; + + @override + Future> search(String query, + [Map? extras]) async => + []; + + @override + Future androidAdjustRemoteVolume( + AndroidVolumeDirection direction) async {} + + @override + Future androidSetRemoteVolume(int volumeIndex) async {} +} + +/// This mixin provides default implementations of [fastForward], [rewind], +/// [seekForward] and [seekBackward] which are all defined in terms of your own +/// implementation of [seek]. +mixin SeekHandler on BaseAudioHandler { + _Seeker? _seeker; + + @override + Future fastForward() => + _seekRelative(AudioService.config.fastForwardInterval); + + @override + Future rewind() => _seekRelative(-AudioService.config.rewindInterval); + + @override + Future seekForward(bool begin) async => _seekContinuously(begin, 1); + + @override + Future seekBackward(bool begin) async => _seekContinuously(begin, -1); + + /// Jumps away from the current position by [offset]. + Future _seekRelative(Duration offset) async { + var newPosition = playbackState.nvalue!.position + offset; + // Make sure we don't jump out of bounds. + if (newPosition < Duration.zero) { + newPosition = Duration.zero; + } + final duration = mediaItem.nvalue?.duration ?? Duration.zero; + if (newPosition > duration) { + newPosition = duration; + } + // Perform the jump via a seek. + await seek(newPosition); + } + + /// Begins or stops a continuous seek in [direction]. After it begins it will + /// continue seeking forward or backward by 10 seconds within the audio, at + /// intervals of 1 second in app time. + void _seekContinuously(bool begin, int direction) { + _seeker?.stop(); + if (begin && mediaItem.nvalue?.duration != null) { + _seeker = _Seeker(this, Duration(seconds: 10 * direction), + const Duration(seconds: 1), mediaItem.nvalue!.duration!) + ..start(); + } + } +} + +class _Seeker { + final AudioHandler handler; + final Duration positionInterval; + final Duration stepInterval; + final Duration duration; + bool _running = false; + + _Seeker( + this.handler, + this.positionInterval, + this.stepInterval, + this.duration, + ); + + Future start() async { + _running = true; + while (_running) { + var newPosition = + handler.playbackState.nvalue!.position + positionInterval; + if (newPosition < Duration.zero) newPosition = Duration.zero; + if (newPosition > duration) newPosition = duration; + handler.seek(newPosition); + await Future.delayed(stepInterval); + } + } + + void stop() { + _running = false; + } +} + +/// This mixin provides default implementations of methods for updating and +/// navigating the queue. When using this mixin, you must add a list of +/// [MediaItem]s to [queue], override [skipToQueueItem] and initialise the queue +/// index (e.g. by calling [skipToQueueItem] with the initial queue index). The +/// [skipToNext] and [skipToPrevious] default implementations are defined by +/// this mixin in terms of your own implementation of [skipToQueueItem]. +mixin QueueHandler on BaseAudioHandler { + @override + Future addQueueItem(MediaItem mediaItem) async { + queue.add(queue.nvalue!..add(mediaItem)); + await super.addQueueItem(mediaItem); + } + + @override + Future addQueueItems(List mediaItems) async { + queue.add(queue.nvalue!..addAll(mediaItems)); + await super.addQueueItems(mediaItems); + } + + @override + Future insertQueueItem(int index, MediaItem mediaItem) async { + queue.add(queue.nvalue!..insert(index, mediaItem)); + await super.insertQueueItem(index, mediaItem); + } + + @override + Future updateQueue(List queue) async { + this.queue.add( + this.queue.nvalue!..replaceRange(0, this.queue.nvalue!.length, queue)); + await super.updateQueue(queue); + } + + @override + Future updateMediaItem(MediaItem mediaItem) async { + queue.add(queue.nvalue!..[queue.nvalue!.indexOf(mediaItem)] = mediaItem); + await super.updateMediaItem(mediaItem); + } + + @override + Future removeQueueItem(MediaItem mediaItem) async { + queue.add(queue.nvalue!..remove(mediaItem)); + await super.removeQueueItem(mediaItem); + } + + @override + Future skipToNext() async { + await _skip(1); + await super.skipToNext(); + } + + @override + Future skipToPrevious() async { + await _skip(-1); + await super.skipToPrevious(); + } + + /// This should be overridden to skip to the queue item at [index]. + /// Implementations should broadcast the new queue index via [playbackState], + /// broadcast the new media item via [mediaItem], and potentially issue + /// instructions to start the new item playing. Some implementations may + /// choose to automatically play when skipping to a queue item while others + /// may prefer to play the new item only if the player was already playing + /// another item beforehand. + /// + /// An example implementation may look like: + /// + /// ```dart + /// playbackState.add(playbackState.value!.copyWith(queueIndex: index)); + /// mediaItem.add(queue.value![index]); + /// player.playAtIndex(index); // use your player's respective API + /// await super.skipToQueueItem(index); + /// ``` + @override + Future skipToQueueItem(int index) async { + await super.skipToQueueItem(index); + } + + Future _skip(int offset) async { + final queue = this.queue.nvalue!; + final index = playbackState.nvalue!.queueIndex!; + if (index < 0 || index >= queue.length) return; + return skipToQueueItem(index + offset); + } +} + +/// The available shuffle modes for the queue. +enum AudioServiceShuffleMode { + /// The queue will not be shuffled. + none, + + /// The whole queue will be shuffled. + all, + + /// A group of items will be shuffled. This corresponds to Android's + /// [SHUFFLE_MODE_GROUP](https://developer.android.com/reference/androidx/media2/common/SessionPlayer#SHUFFLE_MODE_GROUP). + group, +} + +/// The available repeat modes. +/// +/// This defines how media items should repeat when the current one is finished. +enum AudioServiceRepeatMode { + /// The current media item or queue will not repeat. + none, + + /// The current media item will repeat. + one, + + /// Playback will continue looping through all media items in the current list. + all, + + /// UNIMPLEMENTED - see https://github.com/ryanheise/audio_service/issues/560 + /// + /// This corresponds to Android's [REPEAT_MODE_GROUP](https://developer.android.com/reference/androidx/media2/common/SessionPlayer#REPEAT_MODE_GROUP). + /// + /// This could represent a playlist that is a smaller subset of all media items. + group, +} + +/// The configuration options to use when intializing the [AudioService]. +class AudioServiceConfig { + /// Whether on Android a media button click wakes up the media session and + /// resumes playback. + // TODO: either fix, or remove this https://github.com/ryanheise/audio_service/issues/638 + final bool androidResumeOnClick; + + /// The ID of the media notification channel. This will default to + /// `.channel` where `` is your app's + /// package name. e.g. `com.mycompany.myapp.channel`. + /// + /// If your app uses multiple notification channels, make sure each channel + /// has a unique ID so that they don't clash. It is recommended to override + /// the default ID. + /// + /// NOTE: After a user installs and runs your app, a channel will be created + /// with this ID and will show up in the app's settings. If you subsequently + /// change this channel ID here, it will result in a new channel being created + /// under the new ID leaving the old channel still visible. Therefore, if your + /// app has already been published, you might prefer to keep using the same + /// channel ID that you are currently using. + final String? androidNotificationChannelId; + + /// The name of the media notification channel, that is visible to user in + /// settings of your app. + final String androidNotificationChannelName; + + /// A description of the media notification channel, that is visible to user + /// in settings of your app. + final String? androidNotificationChannelDescription; + + /// The color to use on the background of the notification on Android. This + /// should be a non-transparent color. + final Color? notificationColor; + + /// The icon resource to be used in the Android media notification, specified + /// like an XML resource reference. This should be a monochrome white icon on + /// a transparent background. The default value is `"mipmap/ic_launcher"`. + final String androidNotificationIcon; + + /// Whether notification badges (also known as notification dots) should + /// appear on a launcher icon when the app has an active notification. + final bool androidShowNotificationBadge; + + /// Whether the application activity will be opened on click on notification. + final bool androidNotificationClickStartsActivity; + + /// Whether the notification can be swiped away. + /// + /// If you set this to true, [androidStopForegroundOnPause] must be true as well, + /// otherwise this will not do anything, because when foreground service is active, + /// it forces notification to be ongoing. + final bool androidNotificationOngoing; + + /// Whether the Android service should switch to a lower priority state when + /// playback is paused allowing the user to swipe away the notification. Note + /// that while in this lower priority state, the operating system will also be + /// able to kill your service at any time to reclaim resources. + final bool androidStopForegroundOnPause; + + /// If not null, causes the artwork specified by [MediaItem.artUri] to be + /// downscaled to this maximum pixel width. If the resolution of your artwork + /// is particularly high, this can help to conserve memory. If specified, + /// [artDownscaleHeight] must also be specified. + final int? artDownscaleWidth; + + /// If not null, causes the artwork specified by [MediaItem.artUri] to be + /// downscaled to this maximum pixel height. If the resolution of your artwork + /// is particularly high, this can help to conserve memory. If specified, + /// [artDownscaleWidth] must also be specified. + final int? artDownscaleHeight; + + /// The interval to be used in [AudioHandler.fastForward]. This value will + /// also be used on iOS to render the skip-forward button. This value must be + /// positive. + final Duration fastForwardInterval; + + /// The interval to be used in [AudioHandler.rewind]. This value will also be + /// used on iOS to render the skip-backward button. This value must be + /// positive. + final Duration rewindInterval; + + /// By default artworks are loaded only when the item is fed into [AudioHandler.mediaItem]. + /// + /// If set to `true`, artworks for items start loading as soon as they are added to + /// [AudioHandler.queue]. + final bool preloadArtwork; + + /// Extras to report on Android in response to an `onGetRoot` request. + final Map? androidBrowsableRootExtras; + + /// Creates a configuration object. + const AudioServiceConfig({ + this.androidResumeOnClick = true, + this.androidNotificationChannelId, + this.androidNotificationChannelName = 'Notifications', + this.androidNotificationChannelDescription, + this.notificationColor, + this.androidNotificationIcon = 'mipmap/ic_launcher', + this.androidShowNotificationBadge = false, + this.androidNotificationClickStartsActivity = true, + this.androidNotificationOngoing = false, + this.androidStopForegroundOnPause = true, + this.artDownscaleWidth, + this.artDownscaleHeight, + this.fastForwardInterval = const Duration(seconds: 10), + this.rewindInterval = const Duration(seconds: 10), + this.preloadArtwork = false, + this.androidBrowsableRootExtras, + }) : assert((artDownscaleWidth != null) == (artDownscaleHeight != null)), + assert( + !androidNotificationOngoing || androidStopForegroundOnPause, + 'The androidNotificationOngoing will make no effect with androidStopForegroundOnPause set to false', + ); + + AudioServiceConfigMessage _toMessage() => AudioServiceConfigMessage( + androidResumeOnClick: androidResumeOnClick, + androidNotificationChannelId: androidNotificationChannelId, + androidNotificationChannelName: androidNotificationChannelName, + androidNotificationChannelDescription: + androidNotificationChannelDescription, + notificationColor: notificationColor, + androidNotificationIcon: androidNotificationIcon, + androidShowNotificationBadge: androidShowNotificationBadge, + androidNotificationClickStartsActivity: + androidNotificationClickStartsActivity, + androidNotificationOngoing: androidNotificationOngoing, + androidStopForegroundOnPause: androidStopForegroundOnPause, + artDownscaleWidth: artDownscaleWidth, + artDownscaleHeight: artDownscaleHeight, + fastForwardInterval: fastForwardInterval, + rewindInterval: rewindInterval, + preloadArtwork: preloadArtwork, + androidBrowsableRootExtras: androidBrowsableRootExtras, + ); + + @override + String toString() => '${_toMessage().toMap()}'; +} + +/// Key/value codes for use in [MediaItem.extras] and +/// [AudioServiceConfig.androidBrowsableRootExtras] to influence how Android +/// Auto will style browsable and playable media items. +class AndroidContentStyle { + /// Set this key to `true` in [AudioServiceConfig.androidBrowsableRootExtras] + /// to declare that content style is supported. + static const supportedKey = 'android.media.browse.CONTENT_STYLE_SUPPORTED'; + + /// The key in [MediaItem.extras] and + /// [AudioServiceConfig.androidBrowsableRootExtras] to configure the content + /// style for playable items. The value can be any of the `*ItemHintValue` + /// constants defined in this class. + static const playableHintKey = + 'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT'; + + /// The key in [MediaItem.extras] and + /// [AudioServiceConfig.androidBrowsableRootExtras] to configure the content + /// style for browsable items. The value can be any of the `*ItemHintValue` + /// constants defined in this class. + static const browsableHintKey = + 'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT'; + + /// Specifies that items should be presented as lists. + static const listItemHintValue = 1; + + /// Specifies that items should be presented as grids. + static const gridItemHintValue = 2; + + /// Specifies that items should be presented as lists with vector icons. + static const categoryListItemHintValue = 3; + + /// Specifies that items should be presented as grids with vector icons. + static const categoryGridItemHintValue = 4; +} + +/// (Maybe) temporary. +extension AudioServiceValueStream on ValueStream { + /// Returns `this`. + @Deprecated('Use "this" instead. Will be removed before the release') + ValueStream get stream => this; +} + +extension _MediaItemMessageExtension on MediaItemMessage { + MediaItem toPlugin() => MediaItem( + id: id, + album: album, + title: title, + artist: artist, + genre: genre, + duration: duration, + artUri: artUri, + playable: playable, + displayTitle: displayTitle, + displaySubtitle: displaySubtitle, + displayDescription: displayDescription, + rating: rating?.toPlugin(), + isLive: isLive, + extras: extras, + ); +} + +extension _RatingMessageExtension on RatingMessage { + Rating toPlugin() => Rating._(RatingStyle.values[type.index], value); +} + +extension _AndroidVolumeDirectionMessageExtension + on AndroidVolumeDirectionMessage { + AndroidVolumeDirection toPlugin() => AndroidVolumeDirection.values[index]!; +} + +extension _MediaButtonMessageExtension on MediaButtonMessage { + MediaButton toPlugin() => MediaButton.values[index]; +} + +/// An enum of volume direction controls on Android. +class AndroidVolumeDirection { + /// Lower the ringer volume. + static final lower = AndroidVolumeDirection._(-1); + + /// Keep the previous ringer volume. + static final same = AndroidVolumeDirection._(0); + + /// Raise the ringer volume. + static final raise = AndroidVolumeDirection._(1); + + /// A map of indices to values. + static final values = { + -1: lower, + 0: same, + 1: raise, + }; + + /// The index for this enum value. + final int index; + + AndroidVolumeDirection._(this.index); + + @override + String toString() => '$index'; +} + +/// An enumeration of different volume control types on Android. +enum AndroidVolumeControlType { + /// The volume cannot be changed. + fixed, + + /// The volume can be adjusted relatively. + relative, + + /// The volume can be set using an absolute value. + absolute, +} + +/// Information about volume control for either local or remote playback +/// depending on the subclass. +abstract class AndroidPlaybackInfo { + AndroidPlaybackInfoMessage _toMessage(); + + @override + String toString() => '${_toMessage().toMap()}'; +} + +/// Playback information for remote volume handling. +class RemoteAndroidPlaybackInfo extends AndroidPlaybackInfo { + //final AndroidAudioAttributes audioAttributes; + + /// The type of volume control supported by the session. + final AndroidVolumeControlType volumeControlType; + + /// The maximum volume supported. + final int maxVolume; + + /// The current volume. + final int volume; + + // ignore: public_member_api_docs + RemoteAndroidPlaybackInfo({ + required this.volumeControlType, + required this.maxVolume, + required this.volume, + }); + + /// Creates a copy of this object with fields replaced. + AndroidPlaybackInfo copyWith({ + AndroidVolumeControlType? volumeControlType, + int? maxVolume, + int? volume, + }) => + RemoteAndroidPlaybackInfo( + volumeControlType: volumeControlType ?? this.volumeControlType, + maxVolume: maxVolume ?? this.maxVolume, + volume: volume ?? this.volume, + ); + + @override + bool operator ==(Object other) => + other.runtimeType == runtimeType && + other is RemoteAndroidPlaybackInfo && + volumeControlType == other.volumeControlType && + maxVolume == other.maxVolume && + volume == other.volume; + + @override + int get hashCode => Object.hash(volumeControlType, maxVolume, volume); + + @override + RemoteAndroidPlaybackInfoMessage _toMessage() => + RemoteAndroidPlaybackInfoMessage( + volumeControlType: + AndroidVolumeControlTypeMessage.values[volumeControlType.index], + maxVolume: maxVolume, + volume: volume, + ); +} + +/// Playback information for local volume handling. +class LocalAndroidPlaybackInfo extends AndroidPlaybackInfo { + @override + bool operator ==(Object other) => other.runtimeType == runtimeType; + + @override + int get hashCode => 0; + + @override + LocalAndroidPlaybackInfoMessage _toMessage() => + const LocalAndroidPlaybackInfoMessage(); +} + +/// This class is deprecated. Use the stream subjects in [BaseAudioHandler] +/// instead. +@Deprecated("Use stream subjects in BaseAudioHandler instead.") +class AudioServiceBackground { + static SwitchAudioHandler get _handler => + AudioService._handler as SwitchAudioHandler; + static Completer? _startCompleter; + + /// Deprecated. Use [AudioHandler.playbackState] instead. + @Deprecated("Use AudioHandler.playbackState instead.") + static PlaybackState get state => + _handler.playbackState.nvalue ?? PlaybackState(); + + /// Deprecated. Use [AudioHandler.queue] instead. + @Deprecated("Use AudioHandler.queue instead.") + static List? get queue => _handler.queue.nvalue; + + /// Deprecated. Use [AudioService.init] instead. + @Deprecated("Use AudioService.init instead") + static Future run(BackgroundAudioTask Function() taskBuilder) async { + final task = taskBuilder(); + _startCompleter!.complete(task); + } + + /// Deprecated. Use [BaseAudioHandler.playbackState] instead. + @Deprecated("Use BaseAudioHandler.playbackState instead.") + static Future setState({ + List? controls, + List? systemActions, + AudioProcessingState? processingState, + bool? playing, + Duration? position, + Duration? bufferedPosition, + double? speed, + DateTime? updateTime, + List? androidCompactActions, + AudioServiceRepeatMode? repeatMode, + AudioServiceShuffleMode? shuffleMode, + }) async { + final oldState = _handler.playbackState.nvalue!; + _taskHandler.playbackState.add(PlaybackState( + controls: controls ?? oldState.controls, + systemActions: systemActions?.toSet() ?? oldState.systemActions, + processingState: processingState ?? oldState.processingState, + playing: playing ?? oldState.playing, + updatePosition: position ?? oldState.position, + bufferedPosition: bufferedPosition ?? oldState.bufferedPosition, + speed: speed ?? oldState.speed, + androidCompactActionIndices: + androidCompactActions ?? oldState.androidCompactActionIndices, + repeatMode: repeatMode ?? oldState.repeatMode, + shuffleMode: shuffleMode ?? oldState.shuffleMode, + )); + } + + static _BackgroundAudioHandler get _taskHandler => + _handler.inner as _BackgroundAudioHandler; + + /// Deprecated. Use [BaseAudioHandler.queue] instead. + @Deprecated("Use BaseAudioHandler.queue instead.") + static Future setQueue(List queue, + {bool preloadArtwork = false}) async { + if (preloadArtwork) { + // ignore: avoid_print + print( + 'WARNING: preloadArtwork is not enabled! ' + 'This is deprecated and must be set via AudioService.init()', + ); + } + _taskHandler.queue.add(queue); + } + + /// Deprecated. Use [BaseAudioHandler.mediaItem] instead. + @Deprecated("Use BaseAudioHandler.mediaItem instead.") + static Future setMediaItem(MediaItem mediaItem) async { + _taskHandler.mediaItem.add(mediaItem); + } + + /// Deprecated. Use [AudioHandler.subscribeToChildren] instead. + @Deprecated("Use AudioHandler.subscribeToChildren instead.") + static Future notifyChildrenChanged( + [String parentMediaId = AudioService.browsableRootId]) async { + await _platform.notifyChildrenChanged( + NotifyChildrenChangedRequest(parentMediaId: parentMediaId)); + } + + /// Deprecated. Use [AudioService.androidForceEnableMediaButtons] instead. + @Deprecated("Use AudioService.androidForceEnableMediaButtons instead.") + static Future androidForceEnableMediaButtons() async { + await AudioService.androidForceEnableMediaButtons(); + } + + /// Deprecated. Use [BaseAudioHandler.customEvent] instead. + @Deprecated("Use BaseAudioHandler.customEvent instead.") + static void sendCustomEvent(dynamic event) { + _taskHandler.customEvent.add(event); + } +} + +class _HandlerCallbacks extends AudioHandlerCallbacks { + final _handlerCompleter = Completer(); + + Future get handlerFuture => _handlerCompleter.future; + + void setHandler(AudioHandler handler) => _handlerCompleter.complete(handler); + + @override + Future addQueueItem(AddQueueItemRequest request) async => + (await handlerFuture).addQueueItem(request.mediaItem.toPlugin()); + + @override + Future androidAdjustRemoteVolume( + AndroidAdjustRemoteVolumeRequest request) async => + (await handlerFuture) + .androidAdjustRemoteVolume(request.direction.toPlugin()); + + @override + Future androidSetRemoteVolume( + AndroidSetRemoteVolumeRequest request) async => + (await handlerFuture).androidSetRemoteVolume(request.volumeIndex); + + @override + Future click(ClickRequest request) async { + return (await handlerFuture).click(request.button.toPlugin()); + } + + @override + Future customAction(CustomActionRequest request) async => + (await handlerFuture).customAction(request.name, request.extras); + + @override + Future fastForward(FastForwardRequest request) async => + (await handlerFuture).fastForward(); + + @override + Future getChildren(GetChildrenRequest request) async { + final mediaItems = + await _onLoadChildren(request.parentMediaId, request.options); + return GetChildrenResponse( + children: mediaItems.map((item) => item._toMessage()).toList()); + } + + @override + Future getMediaItem(GetMediaItemRequest request) async { + return GetMediaItemResponse( + mediaItem: (await (await handlerFuture).getMediaItem(request.mediaId)) + ?._toMessage()); + } + + @override + Future insertQueueItem(InsertQueueItemRequest request) async => + (await handlerFuture) + .insertQueueItem(request.index, request.mediaItem.toPlugin()); + + @override + Future onNotificationClicked( + OnNotificationClickedRequest request) async { + AudioService._notificationClicked.add(request.clicked); + } + + @override + Future onNotificationDeleted( + OnNotificationDeletedRequest request) async => + (await handlerFuture).onNotificationDeleted(); + + @override + Future onTaskRemoved(OnTaskRemovedRequest request) async => + (await handlerFuture).onTaskRemoved(); + + @override + Future pause(PauseRequest request) async => + (await handlerFuture).pause(); + + @override + Future play(PlayRequest request) async => (await handlerFuture).play(); + + @override + Future playFromMediaId(PlayFromMediaIdRequest request) async => + (await handlerFuture).playFromMediaId(request.mediaId, request.extras); + + @override + Future playFromSearch(PlayFromSearchRequest request) async => + (await handlerFuture).playFromSearch(request.query, request.extras); + + @override + Future playFromUri(PlayFromUriRequest request) async => + (await handlerFuture).playFromUri(request.uri, request.extras); + + @override + Future playMediaItem(PlayMediaItemRequest request) async => + (await handlerFuture).playMediaItem(request.mediaItem.toPlugin()); + + @override + Future prepare(PrepareRequest request) async => + (await handlerFuture).prepare(); + + @override + Future prepareFromMediaId(PrepareFromMediaIdRequest request) async => + (await handlerFuture).prepareFromMediaId(request.mediaId, request.extras); + + @override + Future prepareFromSearch(PrepareFromSearchRequest request) async => + (await handlerFuture).prepareFromSearch(request.query, request.extras); + + @override + Future prepareFromUri(PrepareFromUriRequest request) async => + (await handlerFuture).prepareFromUri(request.uri, request.extras); + + @override + Future removeQueueItem(RemoveQueueItemRequest request) async => + (await handlerFuture).removeQueueItem(request.mediaItem.toPlugin()); + + @override + Future removeQueueItemAt(RemoveQueueItemAtRequest request) async => + (await handlerFuture).removeQueueItemAt(request.index); + + @override + Future rewind(RewindRequest request) async => + (await handlerFuture).rewind(); + + @override + Future search(SearchRequest request) async => SearchResponse( + mediaItems: + (await (await handlerFuture).search(request.query, request.extras)) + .map((item) => item._toMessage()) + .toList()); + + @override + Future seek(SeekRequest request) async => + (await handlerFuture).seek(request.position); + + @override + Future seekBackward(SeekBackwardRequest request) async => + (await handlerFuture).seekBackward(request.begin); + + @override + Future seekForward(SeekForwardRequest request) async => + (await handlerFuture).seekForward(request.begin); + + @override + Future setCaptioningEnabled( + SetCaptioningEnabledRequest request) async => + (await handlerFuture).setCaptioningEnabled(request.enabled); + + @override + Future setRating(SetRatingRequest request) async => + (await handlerFuture) + .setRating(request.rating.toPlugin(), request.extras); + + @override + Future setRepeatMode(SetRepeatModeRequest request) async => + (await handlerFuture).setRepeatMode( + AudioServiceRepeatMode.values[request.repeatMode.index]); + + @override + Future setShuffleMode(SetShuffleModeRequest request) async => + (await handlerFuture).setShuffleMode( + AudioServiceShuffleMode.values[request.shuffleMode.index]); + + @override + Future setSpeed(SetSpeedRequest request) async => + (await handlerFuture).setSpeed(request.speed); + + @override + Future skipToNext(SkipToNextRequest request) async => + (await handlerFuture).skipToNext(); + + @override + Future skipToPrevious(SkipToPreviousRequest request) async => + (await handlerFuture).skipToPrevious(); + + @override + Future skipToQueueItem(SkipToQueueItemRequest request) async => + (await handlerFuture).skipToQueueItem(request.index); + + @override + Future stop(StopRequest request) async => (await handlerFuture).stop(); + + final Map>> _childrenSubscriptions = + {}; + + Future> _onLoadChildren( + String parentMediaId, Map? options) async { + var childrenSubscription = _childrenSubscriptions[parentMediaId]; + if (childrenSubscription == null) { + childrenSubscription = _childrenSubscriptions[parentMediaId] = + (await handlerFuture).subscribeToChildren(parentMediaId); + childrenSubscription.listen((Map? options) { + // Notify clients that the children of [parentMediaId] have changed. + _platform.notifyChildrenChanged(NotifyChildrenChangedRequest( + parentMediaId: parentMediaId, + options: options, + )); + }); + } + return await (await handlerFuture).getChildren(parentMediaId, options); + } +} + +/// Backwards compatible extensions on rxdart's ValueStream +extension _ValueStreamExtension on ValueStream { + /// Backwards compatible version of valueOrNull. + T? get nvalue => hasValue ? value : null; +} + +/// This widget is no longer required and has been deprecated. +@Deprecated("This widget is no longer required and can be safely removed.") +class AudioServiceWidget extends StatelessWidget { + /// Deprecated. + final Widget child; + + /// Deprecated. + const AudioServiceWidget({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + return child; + } +} diff --git a/third_party/audio_service/pubspec.yaml b/third_party/audio_service/pubspec.yaml new file mode 100644 index 00000000..c91282b8 --- /dev/null +++ b/third_party/audio_service/pubspec.yaml @@ -0,0 +1,67 @@ +name: audio_service +description: Flutter plugin to play audio in the background while the screen is off. +version: 0.18.19 +repository: https://github.com/ryanheise/audio_service/tree/minor/audio_service +issue_tracker: https://github.com/ryanheise/audio_service/issues +topics: + - audio + - background + +environment: + sdk: ^3.6.0 + flutter: ">=3.27.0" + +dependencies: + # Use these deps for local development + # audio_service_platform_interface: + # path: ../audio_service_platform_interface + # audio_service_web: + # path: ../audio_service_web + + # Use these deps when pushing to origin (for the benefit of testers) + # audio_service_platform_interface: + # git: + # url: https://github.com/ryanheise/audio_service.git + # ref: one-isolate + # path: audio_service_platform_interface + # audio_service_web: + # git: + # url: https://github.com/ryanheise/audio_service.git + # ref: one-isolate + # path: audio_service_web + + # Use these deps when publishing. + audio_service_platform_interface: ^0.1.3 + audio_service_web: ^0.1.4 + + audio_session: '>=0.1.25 <0.3.0' + rxdart: '>=0.26.0 <0.29.0' + flutter_cache_manager: ^3.3.1 + clock: ^1.1.0 + js: '>=0.6.3 <0.8.0' + flutter: + sdk: flutter + flutter_web_plugins: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + mockito: ^5.0.0 + fake_async: ^1.2.0 + flutter_lints: ^3.0.1 + +flutter: + plugin: + platforms: + android: + package: com.ryanheise.audioservice + pluginClass: AudioServicePlugin + ios: + pluginClass: AudioServicePlugin + sharedDarwinSource: true + macos: + pluginClass: AudioServicePlugin + sharedDarwinSource: true + web: + default_package: audio_service_web