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:
+
+
+
+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