feat(player): add Mornye media notification controls
Sync notification favorites with Library, add Mornye transport icons and an output action on legacy Android, and expose the iOS favorite command. Preserve playback position when controls refresh. Vendor audio_service 0.18.19 with a small Android patch for custom notification actions. Modern Android keeps its system output switcher and transport layout.
No files matched your search
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -16,6 +16,7 @@ analyzer:
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
- third_party/audio_service/**
|
||||
language:
|
||||
strict-casts: true
|
||||
strict-inference: true
|
||||
|
||||
@@ -35,6 +35,11 @@
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:localeConfig="@xml/locale_config">
|
||||
<activity
|
||||
android:name=".AudioOutputActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.media.MediaRouter2
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
|
||||
/** A notification launches this activity directly, so output selection has a
|
||||
* visible foreground context even while the Flutter player is in the background.
|
||||
*/
|
||||
class AudioOutputActivity : Activity() {
|
||||
private var opened = false
|
||||
private var pickerTookFocus = false
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (!hasFocus) {
|
||||
if (opened) pickerTookFocus = true
|
||||
return
|
||||
}
|
||||
if (opened) {
|
||||
if (pickerTookFocus) finish()
|
||||
return
|
||||
}
|
||||
opened = true
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="1.65" android:strokeLineCap="round"
|
||||
android:pathData="M5.3,17.2 A9,9 0,1 1,18.7 17.2 M7.6,14.8 A5.8,5.8 0,1 1,16.4 14.8 M9.8,12.4 A2.65,2.65 0,1 1,14.2 12.4" />
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,12.8 L17,21.5 L7,21.5 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#00000000" android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="1.7" android:strokeLineJoin="round"
|
||||
android:pathData="M12,2.5 L14.85,8.6 L21.5,9.4 L16.6,14 L17.9,20.7 L12,17.3 L6.1,20.7 L7.4,14 L2.5,9.4 L9.15,8.6 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,2.5 L14.85,8.6 L21.5,9.4 L16.6,14 L17.9,20.7 L12,17.3 L6.1,20.7 L7.4,14 L2.5,9.4 L9.15,8.6 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,2 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:keep="@drawable/ic_notification_*,@drawable/ic_widget_previous,@drawable/ic_widget_next,@drawable/ic_widget_play,@drawable/ic_widget_pause" />
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<void, ({String favorite, String unfavorite, String output})>((
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<MainShell>
|
||||
|
||||
@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,
|
||||
|
||||
@@ -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<void> Function(MediaItem)? _toggleNotificationFavorite;
|
||||
const _notificationChannel = MethodChannel(
|
||||
'com.zarz.spotiflac/playback_notification',
|
||||
);
|
||||
|
||||
void configurePlaybackNotification({
|
||||
required PlaybackNotification presentation,
|
||||
required Future<void> 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<void> _publishIosNotificationFavorite() async {
|
||||
try {
|
||||
await _notificationChannel.invokeMethod<void>('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<dynamic> customAction(
|
||||
String name, [
|
||||
Map<String, dynamic>? 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;
|
||||
|
||||
@@ -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<MediaControl> 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',
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = <String>[];
|
||||
@@ -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<void>.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<void>();
|
||||
final selected = <String>[];
|
||||
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<void> prepare() async {
|
||||
setAutoMixEnabled(true);
|
||||
await handler.setQueueAndPlay(_tracks);
|
||||
|
||||
@@ -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 = <String>[];
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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<void> play() => _player.play();
|
||||
Future<void> pause() => _player.pause();
|
||||
Future<void> stop() => _player.stop();
|
||||
Future<void> seek(Duration position) => _player.seek(position);
|
||||
Future<void> skipToQueueItem(int i) => _player.seek(Duration.zero, index: i);
|
||||
}
|
||||
```
|
||||
|
||||
Register your `AudioHandler` during app startup:
|
||||
|
||||
```dart
|
||||
Future<void> main() async {
|
||||
// store this in a singleton
|
||||
_audioHandler = await AudioService.init(
|
||||
builder: () => MyAudioHandler(),
|
||||
config: AudioServiceConfig(
|
||||
androidNotificationChannelId: 'com.mycompany.myapp.channel.audio',
|
||||
androidNotificationChannelName: 'Music playback',
|
||||
),
|
||||
);
|
||||
runApp(new MyApp());
|
||||
}
|
||||
```
|
||||
|
||||
### Sending requests to the audio handler from Flutter
|
||||
|
||||
Standard controls:
|
||||
|
||||
```dart
|
||||
_audioHandler.play();
|
||||
_audioHandler.seek(Duration(seconds: 10));
|
||||
_audioHandler.setSpeed(1.5);
|
||||
_audioHandler.pause();
|
||||
_audioHandler.stop();
|
||||
```
|
||||
|
||||
Playing specific media items:
|
||||
|
||||
```dart
|
||||
var item = MediaItem(
|
||||
id: 'https://example.com/audio.mp3',
|
||||
album: 'Album name',
|
||||
title: 'Track title',
|
||||
artist: 'Artist name',
|
||||
duration: const Duration(milliseconds: 123456),
|
||||
artUri: Uri.parse('https://example.com/album.jpg'),
|
||||
);
|
||||
|
||||
_audioHandler.playMediaItem(item);
|
||||
_audioHandler.playFromSearch(queryString);
|
||||
_audioHandler.playFromUri(uri);
|
||||
_audioHandler.playFromMediaId(id);
|
||||
```
|
||||
|
||||
Queue management:
|
||||
|
||||
```dart
|
||||
_audioHandler.addQueueItem(item);
|
||||
_audioHandler.insertQueueItem(1, item);
|
||||
_audioHandler.removeQueueItem(item);
|
||||
_audioHandler.updateQueue([item, ...]);
|
||||
_audioHandler.skipToNext();
|
||||
_audioHandler.skipToPrevious();
|
||||
_audioHandler.skipToQueueItem(2);
|
||||
```
|
||||
|
||||
Looping and shuffling:
|
||||
|
||||
```dart
|
||||
_audioHandler.setRepeatMode(AudioServiceRepeatMode.one); // none/one/all/group
|
||||
_audioHandler.setShuffleMode(AudioServiceShuffleMode.all); // none/all/group
|
||||
```
|
||||
|
||||
Custom actions:
|
||||
|
||||
```dart
|
||||
_audioHandler.customAction('setVolume', {'volume': 0.8});
|
||||
_audioHandler.customAction('saveBookmark');
|
||||
```
|
||||
|
||||
### Broadcasting state changes
|
||||
|
||||
Your audio handler must broadcast state changes so that the system notification and smart watches (etc) know what state to display. Your app's Flutter UI may also listen to these state changes so that it knows what state to display. Thus, the audio handler provides a single source of truth for your audio state to all clients.
|
||||
|
||||
Broadcast the current media item:
|
||||
|
||||
```dart
|
||||
class MyAudioHandler extends BaseAudioHandler ... {
|
||||
...
|
||||
mediaItem.add(item1);
|
||||
...
|
||||
```
|
||||
|
||||
Broadcast the current queue:
|
||||
|
||||
```dart
|
||||
...
|
||||
queue.add(<MediaItem>[item1, item2, item3]);
|
||||
...
|
||||
```
|
||||
|
||||
Broadcast the current playback state:
|
||||
|
||||
```dart
|
||||
...
|
||||
// All options shown:
|
||||
playbackState.add(PlaybackState(
|
||||
// Which buttons should appear in the notification now
|
||||
controls: [
|
||||
MediaControl.skipToPrevious,
|
||||
MediaControl.pause,
|
||||
MediaControl.stop,
|
||||
MediaControl.skipToNext,
|
||||
],
|
||||
// Which other actions should be enabled in the notification
|
||||
systemActions: const {
|
||||
MediaAction.seek,
|
||||
MediaAction.seekForward,
|
||||
MediaAction.seekBackward,
|
||||
},
|
||||
// Which controls to show in Android's compact view.
|
||||
androidCompactActionIndices: const [0, 1, 3],
|
||||
// Whether audio is ready, buffering, ...
|
||||
processingState: AudioProcessingState.ready,
|
||||
// Whether audio is playing
|
||||
playing: true,
|
||||
// The current position as of this update. You should not broadcast
|
||||
// position changes continuously because listeners will be able to
|
||||
// project the current position after any elapsed time based on the
|
||||
// current speed and whether audio is playing and ready. Instead, only
|
||||
// broadcast position updates when they are different from expected (e.g.
|
||||
// buffering, or seeking).
|
||||
updatePosition: Duration(milliseconds: 54321),
|
||||
// The current buffered position as of this update
|
||||
bufferedPosition: Duration(milliseconds: 65432),
|
||||
// The current speed
|
||||
speed: 1.0,
|
||||
// The current queue position
|
||||
queueIndex: 0,
|
||||
));
|
||||
```
|
||||
|
||||
Broadcasting mutations of the current playback state using `copyWith`:
|
||||
|
||||
```dart
|
||||
playbackState.add(playbackState.value.copyWith(
|
||||
// Keep all existing state the same with only the speed changed:
|
||||
speed: newSpeed,
|
||||
));
|
||||
```
|
||||
|
||||
### Listening to state changes
|
||||
|
||||
Listen to changes to the currently playing item from the Flutter UI:
|
||||
|
||||
```dart
|
||||
_audioHandler.mediaItem.listen((MediaItem item) { ... });
|
||||
```
|
||||
|
||||
Listen to changes to the queue from the Flutter UI:
|
||||
|
||||
```dart
|
||||
_audioHandler.queue.listen((List<MediaItem> queue) { ... });
|
||||
```
|
||||
|
||||
Listen to playback state changes from the Flutter UI:
|
||||
|
||||
```dart
|
||||
_audioHandler.playbackState.listen((PlaybackState state) {
|
||||
if (state.playing) ... else ...
|
||||
switch (state.processingState) {
|
||||
case AudioProcessingState.idle: ...
|
||||
case AudioProcessingState.loading: ...
|
||||
case AudioProcessingState.buffering: ...
|
||||
case AudioProcessingState.ready: ...
|
||||
case AudioProcessingState.completed: ...
|
||||
case AudioProcessingState.error: ...
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Listen to a stream of continuous changes to the current playback position:
|
||||
|
||||
```dart
|
||||
AudioService.position.listen((Duration position) { ... });
|
||||
```
|
||||
|
||||
### Advanced features
|
||||
|
||||
Compose multiple audio handler classes:
|
||||
|
||||
```dart
|
||||
_audioHandler = await AudioService.init(
|
||||
builder: () => AnalyticsAudioHandler(
|
||||
PersistingAudioHandler(
|
||||
MyAudioHandler())),
|
||||
);
|
||||
```
|
||||
|
||||
Connecting from another isolate:
|
||||
|
||||
```dart
|
||||
// Wrap audio handler in IsolatedAudioHandler:
|
||||
_audioHandler = await AudioService.init(
|
||||
builder: () => IsolatedAudioHandler(
|
||||
MyAudioHandler(),
|
||||
portName: 'my_audio_handler',
|
||||
),
|
||||
);
|
||||
// From another isolate, obtain a proxy reference:
|
||||
_proxyAudioHandler = await IsolatedAudioHandler.lookup(
|
||||
portName: 'my_audio_handler',
|
||||
);
|
||||
```
|
||||
|
||||
See the full example for how to handle queues/playlists, headset button clicks, media artwork and text to speech.
|
||||
|
||||
## Configuring the audio session
|
||||
|
||||
If your app uses audio, you should tell the operating system what kind of usage scenario your app has and how your app will interact with other audio apps on the device. Different audio apps often have unique requirements. For example, when a navigator app speaks driving instructions, a music player should duck its audio while a podcast player should pause its audio. Depending on which one of these three apps you are building, you will need to configure your app's audio settings and callbacks to appropriately handle these interactions.
|
||||
|
||||
Use the [audio_session](https://pub.dev/packages/audio_session) package to change the default audio session configuration for your app. E.g. for a podcast player, you may use:
|
||||
|
||||
```dart
|
||||
final session = await AudioSession.instance;
|
||||
await session.configure(AudioSessionConfiguration.speech());
|
||||
```
|
||||
|
||||
Each time you invoke an audio plugin to play audio, that plugin will activate your app's shared audio session to inform the operating system that your app is actively playing audio. Depending on the configuration set above, this will also inform other audio apps to either stop playing audio, or possibly continue playing at a lower volume (i.e. ducking). You normally do not need to activate the audio session yourself, however if the audio plugin you use does not activate the audio session, you can activate it yourself:
|
||||
|
||||
```dart
|
||||
// Activate the audio session before playing audio.
|
||||
if (await session.setActive(true)) {
|
||||
// Now play audio.
|
||||
} else {
|
||||
// The request was denied and the app should not play audio
|
||||
}
|
||||
```
|
||||
|
||||
When another app activates its audio session, it similarly may ask your app to pause or duck its audio. Once again, the particular audio plugin you use may automatically pause or duck audio when requested. However, if it does not, you can respond to these events yourself by listening to `session.interruptionEventStream`. Similarly, if the audio plugin doesn't handle unplugged headphone events, you can respond to these yourself by listening to `session.becomingNoisyEventStream`. For more information, consult the documentation for [audio_session](https://pub.dev/packages/audio_session).
|
||||
|
||||
Note: If your app uses a number of different audio plugins, e.g. for audio recording, or text to speech, or background audio, it is possible that those plugins may internally override each other's audio session settings since there is only a single audio session shared by your app. Therefore, it is recommended that you apply your own preferred configuration using audio_session after all other audio plugins have loaded. You may consider asking the developer of each audio plugin you use to provide an option to not overwrite these global settings and allow them be managed externally.
|
||||
|
||||
## Android setup
|
||||
|
||||
1. Make the following changes to your project's `AndroidManifest.xml` file:
|
||||
|
||||
```xml
|
||||
<manifest xmlns:tools="http://schemas.android.com/tools" ...>
|
||||
<!-- ADD THESE TWO PERMISSIONS -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<!-- ALSO ADD THIS PERMISSION IF TARGETING SDK 34 -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
|
||||
<application ...>
|
||||
|
||||
...
|
||||
|
||||
<!-- EDIT THE android:name ATTRIBUTE IN YOUR EXISTING "ACTIVITY" ELEMENT -->
|
||||
<activity android:name="com.ryanheise.audioservice.AudioServiceActivity" ...>
|
||||
...
|
||||
</activity>
|
||||
|
||||
<!-- ADD THIS "SERVICE" element -->
|
||||
<service android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true" tools:ignore="Instantiatable">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- ADD THIS "RECEIVER" element -->
|
||||
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
android:exported="true" tools:ignore="Instantiatable">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
```
|
||||
|
||||
Note: As of Android 12, an app must have permission to restart a foreground service in the background, otherwise a `ForegroundServiceStartNotAllowedException` will be thrown. To avoid such an exception, you can either set `androidStopForegroundOnPause` to `false` in your `AudioServiceConfig` which keeps the service in the foreground during a pause so that restarting the foreground service is unnecessary, or you can keep the default `androidStopForegroundOnPause` setting of `true` (in line with best practices) and request the user to turn of battery optimisation for your app via the [optimize_battery](https://pub.dev/packages/optimize_battery) package. For more information, read [this page](https://developer.android.com/training/monitoring-device-state/doze-standby#support_for_other_use_cases).
|
||||
|
||||
2. If you use any custom icons in notification, create the file `android/app/src/main/res/raw/keep.xml` to prevent them from being stripped during the build process:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:keep="@drawable/*" />
|
||||
```
|
||||
|
||||
By default plugin's default icons are not stripped by R8. If you don't use them, you may selectively strip them. For example, the rules below will keep all your icons and discard all the plugin's:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:keep="@drawable/*"
|
||||
tools:discard="@drawable/audio_service_*"
|
||||
/>
|
||||
```
|
||||
|
||||
For more information about shrinking see [Android documentation](https://developer.android.com/studio/build/shrink-code#keep-resources).
|
||||
|
||||
|
||||
### Custom Android activity
|
||||
|
||||
If your app needs to use its own custom activity, make sure you update your `AndroidManifest.xml` file to reference your activity's class name instead of `AudioServiceActivity`. For example, if your activity class is named `MainActivity`, then use:
|
||||
|
||||
```xml
|
||||
<activity android:name=".MainActivity" ...>
|
||||
```
|
||||
|
||||
Depending on whether you activity is a regular `Activity` or a `FragmentActivity`, you must also include some code to link to audio_service's shared `FlutterEngine`. The easiest way to accomplish this is to inherit that code from one of audio_service's provided base classes.
|
||||
|
||||
1. Integration as an `Activity`:
|
||||
|
||||
```java
|
||||
import com.ryanheise.audioservice.AudioServiceActivity;
|
||||
|
||||
class MainActivity extends AudioServiceActivity {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
2. Integration as a `FragmentActivity`:
|
||||
|
||||
```java
|
||||
import com.ryanheise.audioservice.AudioServiceFragmentActivity;
|
||||
|
||||
class MainActivity extends AudioServiceFragmentActivity {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
You can also write your own activity class from scratch, and override the `provideFlutterEngine`, `getCachedEngineId` and `shouldDestroyEngineWithHost` methods yourself. For inspiration, see the source code of the provided `AudioServiceActivity` and `AudioServiceFragmentActivity` classes.
|
||||
|
||||
## iOS setup
|
||||
|
||||
Insert this in your `Info.plist` file:
|
||||
|
||||
```
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
The example project may be consulted for context.
|
||||
|
||||
Note that the `audio` background mode permits an app to run in the background only for the purpose of playing audio. The OS may kill your process if it sits idly without playing audio, for example, by using a timer to sleep for a few seconds. If your app needs to pause for a few seconds between audio tracks, consider playing a silent audio track to create that effect rather than using an idle timer.
|
||||
|
||||
## macOS setup
|
||||
|
||||
The minimum supported macOS version is 10.12.2 (though this could be changed with some work in the future).
|
||||
Modify the platform line in `macos/Podfile` to look like the following:
|
||||
|
||||
```
|
||||
platform :osx, '10.12.2'
|
||||
```
|
||||
@@ -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.
|
||||
@@ -0,0 +1,73 @@
|
||||
group = "com.ryanheise.audioservice"
|
||||
version = "1.0-SNAPSHOT"
|
||||
val args = listOf("-Xlint:deprecation", "-Xlint:unchecked")
|
||||
|
||||
buildscript {
|
||||
// Uncomment when moving to Kotlin
|
||||
// val kotlinVersion = "2.3.20"
|
||||
val agpVersion = "9.0.1"
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:$agpVersion")
|
||||
// Uncomment when moving to Kotlin
|
||||
// classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile>().configureEach {
|
||||
options.compilerArgs.addAll(args)
|
||||
}
|
||||
|
||||
// Uncomment when moving to Kotlin
|
||||
// val agpMajor = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.substringBefore('.').toInt()
|
||||
// if (agpMajor < 9) {
|
||||
// apply(plugin = "org.jetbrains.kotlin.android")
|
||||
// }
|
||||
|
||||
android {
|
||||
namespace = "com.ryanheise.audioservice"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 19
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
// Uncomment when moving to Kotlin
|
||||
// sourceSets {
|
||||
// getByName("main") {
|
||||
// java.srcDirs("src/main/kotlin")
|
||||
// }
|
||||
// getByName("test") {
|
||||
// java.srcDirs("src/test/kotlin")
|
||||
// }
|
||||
// }
|
||||
|
||||
lint {
|
||||
disable += listOf("InvalidPackage")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.media:media:1.7.0")
|
||||
implementation("androidx.core:core:1.13.1")
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx1536M
|
||||
android.enableR8=true
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = "audio_service"
|
||||
@@ -0,0 +1,2 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
</manifest>
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ryanheise.audioservice;
|
||||
|
||||
public enum AudioProcessingState {
|
||||
idle,
|
||||
loading,
|
||||
buffering,
|
||||
ready,
|
||||
completed,
|
||||
error,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ryanheise.audioservice;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity;
|
||||
import io.flutter.embedding.engine.FlutterEngine;
|
||||
|
||||
public class AudioServiceActivity extends FlutterActivity {
|
||||
@Override
|
||||
public FlutterEngine provideFlutterEngine(@NonNull Context context) {
|
||||
return AudioServicePlugin.getFlutterEngine(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.ryanheise.audioservice;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Bundle;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class AudioServiceConfig {
|
||||
private static final String SHARED_PREFERENCES_NAME = "audio_service_preferences";
|
||||
private static final String KEY_ANDROID_RESUME_ON_CLICK = "androidResumeOnClick";
|
||||
private static final String KEY_ANDROID_NOTIFICATION_CHANNEL_ID = "androidNotificationChannelId";
|
||||
private static final String KEY_ANDROID_NOTIFICATION_CHANNEL_NAME = "androidNotificationChannelName";
|
||||
private static final String KEY_ANDROID_NOTIFICATION_CHANNEL_DESCRIPTION = "androidNotificationChannelDescription";
|
||||
private static final String KEY_NOTIFICATION_COLOR = "notificationColor";
|
||||
private static final String KEY_ANDROID_NOTIFICATION_ICON = "androidNotificationIcon";
|
||||
private static final String KEY_ANDROID_SHOW_NOTIFICATION_BADGE = "androidShowNotificationBadge";
|
||||
private static final String KEY_ANDROID_NOTIFICATION_CLICK_STARTS_ACTIVITY = "androidNotificationClickStartsActivity";
|
||||
private static final String KEY_ANDROID_NOTIFICATION_ONGOING = "androidNotificationOngoing";
|
||||
private static final String KEY_ANDROID_STOP_FOREGROUND_ON_PAUSE = "androidStopForegroundOnPause";
|
||||
private static final String KEY_ART_DOWNSCALE_WIDTH = "artDownscaleWidth";
|
||||
private static final String KEY_ART_DOWNSCALE_HEIGHT = "artDownscaleHeight";
|
||||
private static final String KEY_ACTIVITY_CLASS_NAME = "activityClassName";
|
||||
private static final String KEY_BROWSABLE_ROOT_EXTRAS = "androidBrowsableRootExtras";
|
||||
|
||||
private SharedPreferences preferences;
|
||||
public boolean androidResumeOnClick;
|
||||
public String androidNotificationChannelId;
|
||||
public String androidNotificationChannelName;
|
||||
public String androidNotificationChannelDescription;
|
||||
public int notificationColor;
|
||||
public String androidNotificationIcon;
|
||||
public boolean androidShowNotificationBadge;
|
||||
public boolean androidNotificationClickStartsActivity;
|
||||
public boolean androidNotificationOngoing;
|
||||
public boolean androidStopForegroundOnPause;
|
||||
public int artDownscaleWidth;
|
||||
public int artDownscaleHeight;
|
||||
public String activityClassName;
|
||||
public String browsableRootExtras;
|
||||
|
||||
public AudioServiceConfig(Context context) {
|
||||
preferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE);
|
||||
androidResumeOnClick = preferences.getBoolean(KEY_ANDROID_RESUME_ON_CLICK, true);
|
||||
androidNotificationChannelId = preferences.getString(KEY_ANDROID_NOTIFICATION_CHANNEL_ID, null);
|
||||
androidNotificationChannelName = preferences.getString(KEY_ANDROID_NOTIFICATION_CHANNEL_NAME, null);
|
||||
androidNotificationChannelDescription = preferences.getString(KEY_ANDROID_NOTIFICATION_CHANNEL_DESCRIPTION, null);
|
||||
notificationColor = preferences.getInt(KEY_NOTIFICATION_COLOR, -1);
|
||||
androidNotificationIcon = preferences.getString(KEY_ANDROID_NOTIFICATION_ICON, "mipmap/ic_launcher");
|
||||
androidShowNotificationBadge = preferences.getBoolean(KEY_ANDROID_SHOW_NOTIFICATION_BADGE, false);
|
||||
androidNotificationClickStartsActivity = preferences.getBoolean(KEY_ANDROID_NOTIFICATION_CLICK_STARTS_ACTIVITY, true);
|
||||
androidNotificationOngoing = preferences.getBoolean(KEY_ANDROID_NOTIFICATION_ONGOING, false);
|
||||
androidStopForegroundOnPause = preferences.getBoolean(KEY_ANDROID_STOP_FOREGROUND_ON_PAUSE, true);
|
||||
artDownscaleWidth = preferences.getInt(KEY_ART_DOWNSCALE_WIDTH, -1);
|
||||
artDownscaleHeight = preferences.getInt(KEY_ART_DOWNSCALE_HEIGHT, -1);
|
||||
activityClassName = preferences.getString(KEY_ACTIVITY_CLASS_NAME, null);
|
||||
browsableRootExtras = preferences.getString(KEY_BROWSABLE_ROOT_EXTRAS, null);
|
||||
}
|
||||
|
||||
public void setBrowsableRootExtras(Map<?,?> map) {
|
||||
if (map != null) {
|
||||
JSONObject json = new JSONObject(map);
|
||||
browsableRootExtras = json.toString();
|
||||
} else {
|
||||
browsableRootExtras = null;
|
||||
}
|
||||
}
|
||||
|
||||
public Bundle getBrowsableRootExtras() {
|
||||
if (browsableRootExtras == null) return null;
|
||||
try {
|
||||
JSONObject json = new JSONObject(browsableRootExtras);
|
||||
Bundle extras = new Bundle();
|
||||
for (Iterator<String> it = json.keys(); it.hasNext();) {
|
||||
String key = it.next();
|
||||
try {
|
||||
extras.putInt(key, json.getInt(key));
|
||||
} catch (Exception e1) {
|
||||
try {
|
||||
extras.putBoolean(key, json.getBoolean(key));
|
||||
} catch (Exception e2) {
|
||||
try {
|
||||
extras.putDouble(key, json.getDouble(key));
|
||||
} catch (Exception e3) {
|
||||
try {
|
||||
extras.putString(key, json.getString(key));
|
||||
} catch (Exception e4) {
|
||||
System.out.println("Unsupported extras value for key " + key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return extras;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void save() {
|
||||
preferences.edit()
|
||||
.putBoolean(KEY_ANDROID_RESUME_ON_CLICK, androidResumeOnClick)
|
||||
.putString(KEY_ANDROID_NOTIFICATION_CHANNEL_ID, androidNotificationChannelId)
|
||||
.putString(KEY_ANDROID_NOTIFICATION_CHANNEL_NAME, androidNotificationChannelName)
|
||||
.putString(KEY_ANDROID_NOTIFICATION_CHANNEL_DESCRIPTION, androidNotificationChannelDescription)
|
||||
.putInt(KEY_NOTIFICATION_COLOR, notificationColor)
|
||||
.putString(KEY_ANDROID_NOTIFICATION_ICON, androidNotificationIcon)
|
||||
.putBoolean(KEY_ANDROID_SHOW_NOTIFICATION_BADGE, androidShowNotificationBadge)
|
||||
.putBoolean(KEY_ANDROID_NOTIFICATION_CLICK_STARTS_ACTIVITY, androidNotificationClickStartsActivity)
|
||||
.putBoolean(KEY_ANDROID_NOTIFICATION_ONGOING, androidNotificationOngoing)
|
||||
.putBoolean(KEY_ANDROID_STOP_FOREGROUND_ON_PAUSE, androidStopForegroundOnPause)
|
||||
.putInt(KEY_ART_DOWNSCALE_WIDTH, artDownscaleWidth)
|
||||
.putInt(KEY_ART_DOWNSCALE_HEIGHT, artDownscaleHeight)
|
||||
.putString(KEY_ACTIVITY_CLASS_NAME, activityClassName)
|
||||
.putString(KEY_BROWSABLE_ROOT_EXTRAS, browsableRootExtras)
|
||||
.apply();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.ryanheise.audioservice;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import android.os.Bundle;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity;
|
||||
import io.flutter.embedding.engine.FlutterEngine;
|
||||
|
||||
public class AudioServiceFragmentActivity extends FlutterFragmentActivity {
|
||||
@Override
|
||||
public FlutterEngine provideFlutterEngine(@NonNull Context context) {
|
||||
return AudioServicePlugin.getFlutterEngine(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
AudioServicePlugin.getFlutterEngine(this);
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getCachedEngineId() {
|
||||
AudioServicePlugin.getFlutterEngine(this);
|
||||
return AudioServicePlugin.getFlutterEngineId();
|
||||
}
|
||||
|
||||
// The engine is created and managed by AudioServicePlugin,
|
||||
// it should not be destroyed with the activity.
|
||||
@Override
|
||||
public boolean shouldDestroyEngineWithHost() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Make this protected method public
|
||||
@Override
|
||||
public String getInitialRoute() {
|
||||
return super.getInitialRoute();
|
||||
}
|
||||
|
||||
// Make this protected method public
|
||||
@Override
|
||||
public boolean shouldHandleDeeplinking() {
|
||||
return super.shouldHandleDeeplinking();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ryanheise.audioservice;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public class CustomMediaAction {
|
||||
public final String name;
|
||||
public final Map<?, ?> extras;
|
||||
|
||||
public CustomMediaAction(String name, Map<?, ?> extras) {
|
||||
this.name = name;
|
||||
this.extras = extras;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
CustomMediaAction that = (CustomMediaAction) o;
|
||||
return name.equals(that.name) && Objects.equals(extras, that.extras);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(name, extras);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ryanheise.audioservice;
|
||||
|
||||
public enum MediaButton {
|
||||
media,
|
||||
next,
|
||||
previous
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ryanheise.audioservice;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
public class MediaButtonReceiver extends androidx.media.session.MediaButtonReceiver {
|
||||
public static final String ACTION_CUSTOM = "com.ryanheise.audioservice.intent.action.CUSTOM";
|
||||
public static final String ACTION_NOTIFICATION_DELETE = "com.ryanheise.audioservice.intent.action.ACTION_NOTIFICATION_DELETE";
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent != null && ACTION_CUSTOM.equals(intent.getAction()) && AudioService.instance != null) {
|
||||
AudioService.instance.handleCustomNotificationAction(
|
||||
intent.getStringExtra("action"), intent.getBundleExtra("extras"));
|
||||
return;
|
||||
}
|
||||
if (intent != null
|
||||
&& ACTION_NOTIFICATION_DELETE.equals(intent.getAction())
|
||||
&& AudioService.instance != null) {
|
||||
AudioService.instance.handleDeleteNotification();
|
||||
return;
|
||||
}
|
||||
super.onReceive(context, intent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ryanheise.audioservice;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class MediaControl {
|
||||
public final String icon;
|
||||
public final String label;
|
||||
public final long actionCode;
|
||||
public final CustomMediaAction customAction;
|
||||
|
||||
public MediaControl(String icon, String label, long actionCode, CustomMediaAction customAction) {
|
||||
this.icon = icon;
|
||||
this.label = label;
|
||||
this.actionCode = actionCode;
|
||||
this.customAction = customAction;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (other instanceof MediaControl) {
|
||||
MediaControl otherControl = (MediaControl)other;
|
||||
return icon.equals(otherControl.icon) && label.equals(otherControl.label) && actionCode == otherControl.actionCode && Objects.equals(customAction, otherControl.customAction);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 561 B |
|
After Width: | Height: | Size: 584 B |
|
After Width: | Height: | Size: 157 B |
|
After Width: | Height: | Size: 352 B |
|
After Width: | Height: | Size: 379 B |
|
After Width: | Height: | Size: 410 B |
|
After Width: | Height: | Size: 121 B |
|
After Width: | Height: | Size: 241 B |
|
After Width: | Height: | Size: 267 B |
|
After Width: | Height: | Size: 170 B |
|
After Width: | Height: | Size: 285 B |
|
After Width: | Height: | Size: 344 B |
|
After Width: | Height: | Size: 354 B |
|
After Width: | Height: | Size: 114 B |
|
After Width: | Height: | Size: 389 B |
|
After Width: | Height: | Size: 460 B |
|
After Width: | Height: | Size: 188 B |
|
After Width: | Height: | Size: 538 B |
|
After Width: | Height: | Size: 602 B |
|
After Width: | Height: | Size: 639 B |
|
After Width: | Height: | Size: 152 B |
|
After Width: | Height: | Size: 684 B |
|
After Width: | Height: | Size: 770 B |
|
After Width: | Height: | Size: 315 B |
|
After Width: | Height: | Size: 720 B |
|
After Width: | Height: | Size: 832 B |
|
After Width: | Height: | Size: 857 B |
|
After Width: | Height: | Size: 252 B |
|
After Width: | Height: | Size: 838 B |
|
After Width: | Height: | Size: 952 B |
|
After Width: | Height: | Size: 461 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 316 B |
@@ -0,0 +1,23 @@
|
||||
#
|
||||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html
|
||||
#
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'audio_service'
|
||||
s.version = '0.0.1'
|
||||
s.summary = 'Flutter plugin to play audio in the background while the screen is off.'
|
||||
s.description = <<-DESC
|
||||
Flutter plugin to play audio in the background while the screen is off.
|
||||
DESC
|
||||
s.homepage = 'https://github.com/ryanheise/audio_service'
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'Ryan Heise' => 'ryan@ryanheise.com' }
|
||||
s.source = { :path => '.' }
|
||||
s.source_files = 'audio_service/Sources/audio_service/**/*.{h,m}'
|
||||
s.public_header_files = 'audio_service/Sources/audio_service/include/**/*.h'
|
||||
s.ios.dependency 'Flutter'
|
||||
s.osx.dependency 'FlutterMacOS'
|
||||
s.ios.deployment_target = '12.0'
|
||||
s.osx.deployment_target = '10.14'
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// swift-tools-version: 5.9
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "audio_service",
|
||||
platforms: [
|
||||
.iOS("12.0"),
|
||||
.macOS("10.14")
|
||||
],
|
||||
products: [
|
||||
.library(name: "audio-service", targets: ["audio_service"])
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
.target(
|
||||
name: "audio_service",
|
||||
dependencies: [],
|
||||
cSettings: [
|
||||
.headerSearchPath("include/audio_service")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,577 @@
|
||||
#import "./include/audio_service/AudioServicePlugin.h"
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#import <MediaPlayer/MediaPlayer.h>
|
||||
|
||||
// If you'd like to help, please see the TODO comments below, then open a
|
||||
// GitHub issue to announce your intention to work on a particular feature, and
|
||||
// submit a pull request. We have an open discussion over at issue #10 about
|
||||
// all things iOS if you'd like to discuss approaches or ask for input. Thank
|
||||
// you for your support!
|
||||
|
||||
static NSHashTable<AudioServicePlugin *> *plugins = nil;
|
||||
static FlutterMethodChannel *handlerChannel = nil;
|
||||
static FlutterResult startResult = nil;
|
||||
static MPRemoteCommandCenter *commandCenter = nil;
|
||||
static NSMutableDictionary *mediaItem = nil;
|
||||
static long actionBits = 0;
|
||||
static NSMutableArray *commands;
|
||||
static BOOL _controlsUpdated = NO;
|
||||
static enum AudioProcessingState processingState = ApsIdle;
|
||||
static BOOL playing = NO;
|
||||
static NSNumber *position = nil;
|
||||
static NSNumber *bufferedPosition = nil;
|
||||
static NSNumber *updateTime = nil;
|
||||
static NSNumber *speed = nil;
|
||||
static NSNumber *repeatMode = nil;
|
||||
static NSNumber *shuffleMode = nil;
|
||||
static NSNumber *fastForwardInterval = nil;
|
||||
static NSNumber *rewindInterval = nil;
|
||||
static MPMediaItemArtwork* artwork = nil;
|
||||
static NSMutableDictionary *nowPlayingInfo = nil;
|
||||
|
||||
@implementation AudioServicePlugin {
|
||||
FlutterMethodChannel *_channel;
|
||||
}
|
||||
|
||||
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
|
||||
@synchronized(self) {
|
||||
if (!plugins) {
|
||||
plugins = [NSHashTable weakObjectsHashTable];
|
||||
}
|
||||
AudioServicePlugin *instance = [[AudioServicePlugin alloc] initWithRegistrar:registrar];
|
||||
[registrar addMethodCallDelegate:instance channel:instance.channel];
|
||||
[plugins addObject:instance];
|
||||
if (!handlerChannel) {
|
||||
processingState = ApsIdle;
|
||||
position = @(0);
|
||||
bufferedPosition = @(0);
|
||||
long long msSinceEpoch = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0);
|
||||
updateTime = [NSNumber numberWithLongLong: msSinceEpoch];
|
||||
speed = [NSNumber numberWithDouble: 1.0];
|
||||
repeatMode = @(0);
|
||||
shuffleMode = @(0);
|
||||
nowPlayingInfo = [NSMutableDictionary new];
|
||||
handlerChannel = [FlutterMethodChannel
|
||||
methodChannelWithName:@"com.ryanheise.audio_service.handler.methods"
|
||||
binaryMessenger:[registrar messenger]];
|
||||
[registrar addMethodCallDelegate:instance channel:handlerChannel];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (instancetype)initWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar {
|
||||
self = [super init];
|
||||
NSAssert(self, @"super init cannot be nil");
|
||||
_channel = [FlutterMethodChannel
|
||||
methodChannelWithName:@"com.ryanheise.audio_service.client.methods"
|
||||
binaryMessenger:[registrar messenger]];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (FlutterMethodChannel *)channel {
|
||||
return _channel;
|
||||
}
|
||||
|
||||
- (void)invokeClientMethod:(NSString *)method arguments:(id _Nullable)arguments {
|
||||
for (AudioServicePlugin *plugin in plugins) {
|
||||
[plugin.channel invokeMethod:method arguments:arguments];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)invokeClientMethod:(NSString *)method arguments:(id _Nullable)arguments result:(FlutterResult)result {
|
||||
for (AudioServicePlugin *plugin in plugins) {
|
||||
[plugin.channel invokeMethod:method arguments:arguments result:result];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)activateCommandCenter {
|
||||
//NSLog(@"### activateCommandCenter");
|
||||
commandCenter = [MPRemoteCommandCenter sharedCommandCenter];
|
||||
commands = [NSMutableArray new];
|
||||
[commands addObjectsFromArray:@[
|
||||
commandCenter.stopCommand,
|
||||
commandCenter.pauseCommand,
|
||||
commandCenter.playCommand,
|
||||
commandCenter.skipBackwardCommand,
|
||||
commandCenter.previousTrackCommand,
|
||||
commandCenter.nextTrackCommand,
|
||||
commandCenter.skipForwardCommand,
|
||||
[NSNull null],
|
||||
[NSNull null], // changePlaybackPositionCommand, put below
|
||||
commandCenter.togglePlayPauseCommand,
|
||||
[NSNull null],
|
||||
[NSNull null],
|
||||
[NSNull null],
|
||||
[NSNull null],
|
||||
[NSNull null],
|
||||
[NSNull null],
|
||||
[NSNull null],
|
||||
[NSNull null],
|
||||
commandCenter.changeRepeatModeCommand,
|
||||
[NSNull null],
|
||||
[NSNull null],
|
||||
commandCenter.changeShuffleModeCommand,
|
||||
commandCenter.seekBackwardCommand,
|
||||
commandCenter.seekForwardCommand,
|
||||
commandCenter.changePlaybackRateCommand,
|
||||
]];
|
||||
if (@available(iOS 9.1, macOS 10.12.2, *)) {
|
||||
commands[8] = commandCenter.changePlaybackPositionCommand;
|
||||
}
|
||||
[commandCenter.changePlaybackRateCommand setEnabled:YES];
|
||||
[commandCenter.togglePlayPauseCommand setEnabled:YES];
|
||||
[commandCenter.togglePlayPauseCommand addTarget:self action:@selector(togglePlayPause:)];
|
||||
// TODO: enable more commands
|
||||
// Language options
|
||||
if (@available(iOS 9.0, macOS 10.12.2, *)) {
|
||||
[commandCenter.enableLanguageOptionCommand setEnabled:NO];
|
||||
[commandCenter.disableLanguageOptionCommand setEnabled:NO];
|
||||
}
|
||||
// Rating
|
||||
[commandCenter.ratingCommand setEnabled:NO];
|
||||
// Feedback
|
||||
[commandCenter.likeCommand setEnabled:NO];
|
||||
[commandCenter.dislikeCommand setEnabled:NO];
|
||||
[commandCenter.bookmarkCommand setEnabled:NO];
|
||||
}
|
||||
|
||||
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
|
||||
if ([@"configure" isEqualToString:call.method]) {
|
||||
NSDictionary *args = (NSDictionary *)call.arguments;
|
||||
NSDictionary *configMap = (NSDictionary *)args[@"config"];
|
||||
fastForwardInterval = configMap[@"fastForwardInterval"];
|
||||
rewindInterval = configMap[@"rewindInterval"];
|
||||
result(@{});
|
||||
} else if ([@"setState" isEqualToString:call.method]) {
|
||||
NSDictionary *args = (NSDictionary *)call.arguments;
|
||||
NSDictionary *stateMap = (NSDictionary *)args[@"state"];
|
||||
long long msSinceEpoch;
|
||||
if (stateMap[@"updateTime"] != [NSNull null]) {
|
||||
msSinceEpoch = [stateMap[@"updateTime"] longLongValue];
|
||||
} else {
|
||||
msSinceEpoch = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0);
|
||||
}
|
||||
actionBits = 0;
|
||||
NSArray *controlsArray = stateMap[@"controls"];
|
||||
for (int i = 0; i < controlsArray.count; i++) {
|
||||
NSDictionary *control = (NSDictionary *)controlsArray[i];
|
||||
NSNumber *actionIndex = (NSNumber *)control[@"action"];
|
||||
int actionCode = 1 << [actionIndex intValue];
|
||||
actionBits |= actionCode;
|
||||
}
|
||||
NSArray *systemActionsArray = stateMap[@"systemActions"];
|
||||
for (int i = 0; i < systemActionsArray.count; i++) {
|
||||
NSNumber *actionIndex = (NSNumber *)systemActionsArray[i];
|
||||
int actionCode = 1 << [actionIndex intValue];
|
||||
actionBits |= actionCode;
|
||||
}
|
||||
processingState = [stateMap[@"processingState"] intValue];
|
||||
BOOL oldPlaying = playing;
|
||||
NSNumber *oldSpeed = speed;
|
||||
NSNumber *oldPosition = position;
|
||||
playing = [stateMap[@"playing"] boolValue];
|
||||
position = stateMap[@"updatePosition"];
|
||||
bufferedPosition = stateMap[@"bufferedPosition"];
|
||||
speed = stateMap[@"speed"];
|
||||
repeatMode = stateMap[@"repeatMode"];
|
||||
shuffleMode = stateMap[@"shuffleMode"];
|
||||
updateTime = [NSNumber numberWithLongLong: msSinceEpoch];
|
||||
if (playing && !commandCenter) {
|
||||
#if TARGET_OS_IPHONE
|
||||
[AVAudioSession sharedInstance];
|
||||
#endif
|
||||
[self activateCommandCenter];
|
||||
}
|
||||
[self updateControls];
|
||||
if (playing != oldPlaying ||
|
||||
speed.doubleValue != oldSpeed.doubleValue ||
|
||||
position.longLongValue != oldPosition.longLongValue) {
|
||||
[self updateNowPlayingInfo];
|
||||
}
|
||||
result(@{});
|
||||
} else if ([@"setQueue" isEqualToString:call.method]) {
|
||||
result(@{});
|
||||
} else if ([@"setMediaItem" isEqualToString:call.method]) {
|
||||
NSDictionary *args = (NSDictionary *)call.arguments;
|
||||
mediaItem = args[@"mediaItem"];
|
||||
NSString* artUri = mediaItem[@"artUri"];
|
||||
artwork = nil;
|
||||
if (![artUri isEqual: [NSNull null]]) {
|
||||
NSString* artCacheFilePath = (NSString *)[NSNull null];
|
||||
NSDictionary* extras = mediaItem[@"extras"];
|
||||
if (![extras isEqual: [NSNull null]]) {
|
||||
artCacheFilePath = extras[@"artCacheFile"];
|
||||
}
|
||||
if (![artCacheFilePath isEqual: [NSNull null]]) {
|
||||
#if TARGET_OS_IPHONE
|
||||
UIImage* artImage = [UIImage imageWithContentsOfFile:artCacheFilePath];
|
||||
#else
|
||||
NSImage* artImage = [[NSImage alloc] initWithContentsOfFile:artCacheFilePath];
|
||||
#endif
|
||||
if (artImage != nil) {
|
||||
#if TARGET_OS_IPHONE
|
||||
artwork = [[MPMediaItemArtwork alloc] initWithImage: artImage];
|
||||
#else
|
||||
artwork = [[MPMediaItemArtwork alloc] initWithBoundsSize:artImage.size requestHandler:^NSImage* _Nonnull(CGSize aSize) {
|
||||
return artImage;
|
||||
}];
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
[self updateNowPlayingInfo];
|
||||
result(@{});
|
||||
} else if ([@"setPlaybackInfo" isEqualToString:call.method]) {
|
||||
result(@{});
|
||||
} else if ([@"notifyChildrenChanged" isEqualToString:call.method]) {
|
||||
result(@{});
|
||||
} else if ([@"androidForceEnableMediaButtons" isEqualToString:call.method]) {
|
||||
result(@{});
|
||||
} else if ([@"stopService" isEqualToString:call.method]) {
|
||||
[commandCenter.changePlaybackRateCommand setEnabled:NO];
|
||||
[commandCenter.togglePlayPauseCommand setEnabled:NO];
|
||||
[commandCenter.togglePlayPauseCommand removeTarget:nil];
|
||||
[MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = nil;
|
||||
processingState = ApsIdle;
|
||||
actionBits = 0;
|
||||
[self updateControls];
|
||||
_controlsUpdated = NO;
|
||||
startResult = nil;
|
||||
commandCenter = nil;
|
||||
result(@{});
|
||||
}
|
||||
}
|
||||
|
||||
- (MPRemoteCommandHandlerStatus) play: (MPRemoteCommandEvent *) event {
|
||||
//NSLog(@"play");
|
||||
[handlerChannel invokeMethod:@"play" arguments:@{}];
|
||||
return MPRemoteCommandHandlerStatusSuccess;
|
||||
}
|
||||
|
||||
- (MPRemoteCommandHandlerStatus) pause: (MPRemoteCommandEvent *) event {
|
||||
//NSLog(@"pause");
|
||||
[handlerChannel invokeMethod:@"pause" arguments:@{}];
|
||||
return MPRemoteCommandHandlerStatusSuccess;
|
||||
}
|
||||
|
||||
- (BOOL)updateNowPlayingField:(NSString *)field value:(id)value {
|
||||
if (![value isEqual:nowPlayingInfo[field]]) {
|
||||
if (value != nil && value != [NSNull null]) {
|
||||
//NSLog(@"### %@ = '%@'", field, value);
|
||||
nowPlayingInfo[field] = value;
|
||||
} else {
|
||||
//NSLog(@"### %@ = nil", field);
|
||||
[nowPlayingInfo removeObjectForKey:field];
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (void) updateNowPlayingInfo {
|
||||
BOOL updated = NO;
|
||||
if (mediaItem) {
|
||||
updated |= [self updateNowPlayingField:MPMediaItemPropertyTitle value:mediaItem[@"title"]];
|
||||
updated |= [self updateNowPlayingField:MPMediaItemPropertyAlbumTitle value:mediaItem[@"album"]];
|
||||
updated |= [self updateNowPlayingField:MPMediaItemPropertyArtist value:mediaItem[@"artist"]];
|
||||
NSNumber *duration = mediaItem[@"duration"];
|
||||
if (duration == (id)[NSNull null]) duration = @(0);
|
||||
updated |= [self updateNowPlayingField:MPMediaItemPropertyPlaybackDuration value:([NSNumber numberWithDouble: ([duration doubleValue] / 1000)])];
|
||||
if (@available(iOS 3.0, macOS 10.13.2, *)) {
|
||||
updated |= [self updateNowPlayingField:MPMediaItemPropertyArtwork value:artwork];
|
||||
}
|
||||
}
|
||||
|
||||
if (@available(iOS 10.0, macOS 10.12.2, *)) {
|
||||
updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyMediaType value:@(MPNowPlayingInfoMediaTypeAudio)];
|
||||
}
|
||||
updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyPlaybackRate value:(playing ? speed : [NSNumber numberWithDouble: 0.0])];
|
||||
updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyDefaultPlaybackRate value:(playing ? speed : [NSNumber numberWithDouble: 0.0])];
|
||||
updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyElapsedPlaybackTime value:[NSNumber numberWithDouble:([position doubleValue] / 1000)]];
|
||||
MPNowPlayingInfoCenter *center = [MPNowPlayingInfoCenter defaultCenter];
|
||||
#if TARGET_OS_OSX
|
||||
if (@available(iOS 13.0, macOS 10.12.2, *)) {
|
||||
center.playbackState = playing ? MPNowPlayingPlaybackStatePlaying : MPNowPlayingPlaybackStatePaused;
|
||||
}
|
||||
#endif
|
||||
if (@available(iOS 10.0, macOS 10.12.2, *)) {
|
||||
updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyIsLiveStream value:mediaItem[@"isLive"]];
|
||||
}
|
||||
if (updated) {
|
||||
//NSLog(@"### updating nowPlayingInfo");
|
||||
center.nowPlayingInfo = nowPlayingInfo;
|
||||
}
|
||||
|
||||
// TODO: List of all unused "nowPlayingInfo" keys, we might want to use these at some point:
|
||||
//
|
||||
// * MPNowPlayingInfoCollectionIdentifier
|
||||
// * MPNowPlayingInfoPropertyAvailableLanguageOptions
|
||||
// * MPNowPlayingInfoPropertyAssetURL
|
||||
// * MPNowPlayingInfoPropertyChapterCount
|
||||
// * MPNowPlayingInfoPropertyChapterNumber
|
||||
// * MPNowPlayingInfoPropertyCurrentLanguageOptions
|
||||
// * MPNowPlayingInfoPropertyDefaultPlaybackRate
|
||||
// * MPNowPlayingInfoPropertyCurrentPlaybackDate
|
||||
// * MPNowPlayingInfoPropertyExternalContentIdentifier
|
||||
// * MPNowPlayingInfoPropertyExternalUserProfileIdentifier
|
||||
// * MPNowPlayingInfoPropertyPlaybackProgress
|
||||
// * MPNowPlayingInfoPropertyPlaybackQueueCount
|
||||
// * MPNowPlayingInfoPropertyPlaybackQueueIndex
|
||||
// * MPNowPlayingInfoPropertyServiceIdentifier
|
||||
}
|
||||
|
||||
- (void) updateControls {
|
||||
if (!commandCenter) return;
|
||||
for (enum MediaAction action = AStop; action <= ASetSpeed; action++) {
|
||||
[self updateControl:action];
|
||||
}
|
||||
_controlsUpdated = YES;
|
||||
}
|
||||
|
||||
- (void) updateControl:(enum MediaAction)action {
|
||||
MPRemoteCommand *command = commands[action];
|
||||
if (command == (id)[NSNull null]) return;
|
||||
// Shift the actionBits right until the least significant bit is the tested action bit, and AND that with a 1 at the same position.
|
||||
// All bytes become 0, other than the tested action bit, which will be 0 or 1 according to its status in the actionBits long.
|
||||
BOOL enable = ((actionBits >> action) & 1);
|
||||
if (_controlsUpdated && enable == command.enabled) return;
|
||||
//NSLog(@"## updateControl %@ enable=%@", @(action), @(enable));
|
||||
[command setEnabled:enable];
|
||||
switch (action) {
|
||||
case AStop:
|
||||
if (enable) {
|
||||
[commandCenter.stopCommand addTarget:self action:@selector(stop:)];
|
||||
} else {
|
||||
[commandCenter.stopCommand removeTarget:nil];
|
||||
}
|
||||
break;
|
||||
case APause:
|
||||
if (enable) {
|
||||
[commandCenter.pauseCommand addTarget:self action:@selector(pause:)];
|
||||
} else {
|
||||
[commandCenter.pauseCommand removeTarget:nil];
|
||||
}
|
||||
break;
|
||||
case APlay:
|
||||
if (enable) {
|
||||
[commandCenter.playCommand addTarget:self action:@selector(play:)];
|
||||
} else {
|
||||
[commandCenter.playCommand removeTarget:nil];
|
||||
}
|
||||
break;
|
||||
case ARewind:
|
||||
if (rewindInterval.integerValue > 0) {
|
||||
if (enable) {
|
||||
[commandCenter.skipBackwardCommand addTarget: self action:@selector(skipBackward:)];
|
||||
int rewindIntervalInSeconds = [rewindInterval intValue]/1000;
|
||||
NSNumber *rewindIntervalInSec = [NSNumber numberWithInt: rewindIntervalInSeconds];
|
||||
commandCenter.skipBackwardCommand.preferredIntervals = @[rewindIntervalInSec];
|
||||
} else {
|
||||
[commandCenter.skipBackwardCommand removeTarget:nil];
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ASkipToPrevious:
|
||||
if (enable) {
|
||||
[commandCenter.previousTrackCommand addTarget:self action:@selector(previousTrack:)];
|
||||
} else {
|
||||
[commandCenter.previousTrackCommand removeTarget:nil];
|
||||
}
|
||||
break;
|
||||
case ASkipToNext:
|
||||
if (enable) {
|
||||
[commandCenter.nextTrackCommand addTarget:self action:@selector(nextTrack:)];
|
||||
} else {
|
||||
[commandCenter.nextTrackCommand removeTarget:nil];
|
||||
}
|
||||
break;
|
||||
case AFastForward:
|
||||
if (fastForwardInterval.integerValue > 0) {
|
||||
if (enable) {
|
||||
[commandCenter.skipForwardCommand addTarget: self action:@selector(skipForward:)];
|
||||
int fastForwardIntervalInSeconds = [fastForwardInterval intValue]/1000;
|
||||
NSNumber *fastForwardIntervalInSec = [NSNumber numberWithInt: fastForwardIntervalInSeconds];
|
||||
commandCenter.skipForwardCommand.preferredIntervals = @[fastForwardIntervalInSec];
|
||||
} else {
|
||||
[commandCenter.skipForwardCommand removeTarget:nil];
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ASetRating:
|
||||
// TODO:
|
||||
// commandCenter.ratingCommand
|
||||
// commandCenter.dislikeCommand
|
||||
// commandCenter.bookmarkCommand
|
||||
break;
|
||||
case ASeekTo:
|
||||
if (@available(iOS 9.1, macOS 10.12.2, *)) {
|
||||
if (enable) {
|
||||
[commandCenter.changePlaybackPositionCommand addTarget:self action:@selector(changePlaybackPosition:)];
|
||||
} else {
|
||||
[commandCenter.changePlaybackPositionCommand removeTarget:nil];
|
||||
}
|
||||
}
|
||||
break;
|
||||
case APlayPause:
|
||||
// Automatically enabled.
|
||||
break;
|
||||
case ASetRepeatMode:
|
||||
if (enable) {
|
||||
[commandCenter.changeRepeatModeCommand addTarget:self action:@selector(changeRepeatMode:)];
|
||||
} else {
|
||||
[commandCenter.changeRepeatModeCommand removeTarget:nil];
|
||||
}
|
||||
break;
|
||||
case ASetShuffleMode:
|
||||
if (enable) {
|
||||
[commandCenter.changeShuffleModeCommand addTarget:self action:@selector(changeShuffleMode:)];
|
||||
} else {
|
||||
[commandCenter.changeShuffleModeCommand removeTarget:nil];
|
||||
}
|
||||
break;
|
||||
case ASeekBackward:
|
||||
if (enable) {
|
||||
[commandCenter.seekBackwardCommand addTarget:self action:@selector(seekBackward:)];
|
||||
} else {
|
||||
[commandCenter.seekBackwardCommand removeTarget:nil];
|
||||
}
|
||||
break;
|
||||
case ASeekForward:
|
||||
if (enable) {
|
||||
[commandCenter.seekForwardCommand addTarget:self action:@selector(seekForward:)];
|
||||
} else {
|
||||
[commandCenter.seekForwardCommand removeTarget:nil];
|
||||
}
|
||||
break;
|
||||
case ASetSpeed:
|
||||
if (enable) {
|
||||
// [commandCenter.changePlaybackRateCommand setSupportedPlaybackRates:@[@(1),@(1.5),@(2)]]
|
||||
[commandCenter.changePlaybackRateCommand addTarget:self action:@selector(changePlaybackRate:)];
|
||||
} else {
|
||||
[commandCenter.changePlaybackRateCommand removeTarget:nil];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
- (MPRemoteCommandHandlerStatus) togglePlayPause: (MPRemoteCommandEvent *) event {
|
||||
//NSLog(@"togglePlayPause");
|
||||
[handlerChannel invokeMethod:@"click" arguments:@{
|
||||
@"button":@(0)
|
||||
}];
|
||||
return MPRemoteCommandHandlerStatusSuccess;
|
||||
}
|
||||
|
||||
- (MPRemoteCommandHandlerStatus) stop: (MPRemoteCommandEvent *) event {
|
||||
//NSLog(@"stop");
|
||||
[handlerChannel invokeMethod:@"stop" arguments:@{}];
|
||||
return MPRemoteCommandHandlerStatusSuccess;
|
||||
}
|
||||
|
||||
- (MPRemoteCommandHandlerStatus) nextTrack: (MPRemoteCommandEvent *) event {
|
||||
//NSLog(@"nextTrack");
|
||||
[handlerChannel invokeMethod:@"skipToNext" arguments:@{}];
|
||||
return MPRemoteCommandHandlerStatusSuccess;
|
||||
}
|
||||
|
||||
- (MPRemoteCommandHandlerStatus) previousTrack: (MPRemoteCommandEvent *) event {
|
||||
//NSLog(@"previousTrack");
|
||||
[handlerChannel invokeMethod:@"skipToPrevious" arguments:@{}];
|
||||
return MPRemoteCommandHandlerStatusSuccess;
|
||||
}
|
||||
|
||||
- (MPRemoteCommandHandlerStatus) changePlaybackPosition: (MPChangePlaybackPositionCommandEvent *) event {
|
||||
//NSLog(@"changePlaybackPosition");
|
||||
[handlerChannel invokeMethod:@"seek" arguments: @{
|
||||
@"position":@((long long) (event.positionTime * 1000000.0))
|
||||
}];
|
||||
return MPRemoteCommandHandlerStatusSuccess;
|
||||
}
|
||||
|
||||
- (MPRemoteCommandHandlerStatus) skipForward: (MPRemoteCommandEvent *) event {
|
||||
//NSLog(@"skipForward");
|
||||
[handlerChannel invokeMethod:@"fastForward" arguments:@{}];
|
||||
return MPRemoteCommandHandlerStatusSuccess;
|
||||
}
|
||||
|
||||
- (MPRemoteCommandHandlerStatus) skipBackward: (MPRemoteCommandEvent *) event {
|
||||
//NSLog(@"skipBackward");
|
||||
[handlerChannel invokeMethod:@"rewind" arguments:@{}];
|
||||
return MPRemoteCommandHandlerStatusSuccess;
|
||||
}
|
||||
|
||||
- (MPRemoteCommandHandlerStatus) seekForward: (MPSeekCommandEvent *) event {
|
||||
//NSLog(@"seekForward");
|
||||
BOOL begin = event.type == MPSeekCommandEventTypeBeginSeeking;
|
||||
[handlerChannel invokeMethod:@"seekForward" arguments:@{
|
||||
@"begin":@(begin)
|
||||
}];
|
||||
return MPRemoteCommandHandlerStatusSuccess;
|
||||
}
|
||||
|
||||
- (MPRemoteCommandHandlerStatus) seekBackward: (MPSeekCommandEvent *) event {
|
||||
//NSLog(@"seekBackward");
|
||||
BOOL begin = event.type == MPSeekCommandEventTypeBeginSeeking;
|
||||
[handlerChannel invokeMethod:@"seekBackward" arguments:@{
|
||||
@"begin":@(begin)
|
||||
}];
|
||||
return MPRemoteCommandHandlerStatusSuccess;
|
||||
}
|
||||
|
||||
- (MPRemoteCommandHandlerStatus) changeRepeatMode: (MPChangeRepeatModeCommandEvent *) event {
|
||||
//NSLog(@"changeRepeatMode");
|
||||
int modeIndex;
|
||||
switch (event.repeatType) {
|
||||
case MPRepeatTypeOff:
|
||||
modeIndex = 0;
|
||||
break;
|
||||
case MPRepeatTypeOne:
|
||||
modeIndex = 1;
|
||||
break;
|
||||
// MPRepeatTypeAll
|
||||
default:
|
||||
modeIndex = 2;
|
||||
break;
|
||||
}
|
||||
[handlerChannel invokeMethod:@"setRepeatMode" arguments:@{
|
||||
@"repeatMode":@(modeIndex)
|
||||
}];
|
||||
return MPRemoteCommandHandlerStatusSuccess;
|
||||
}
|
||||
|
||||
- (MPRemoteCommandHandlerStatus) changePlaybackRate: (MPChangePlaybackRateCommandEvent *) event {
|
||||
//NSLog(@"changePlaybackRate");
|
||||
[handlerChannel invokeMethod:@"setSpeed" arguments:@{
|
||||
@"speed":@(event.playbackRate)
|
||||
}];
|
||||
return MPRemoteCommandHandlerStatusSuccess;
|
||||
}
|
||||
|
||||
- (MPRemoteCommandHandlerStatus) changeShuffleMode: (MPChangeShuffleModeCommandEvent *) event {
|
||||
//NSLog(@"changeShuffleMode");
|
||||
int modeIndex;
|
||||
switch (event.shuffleType) {
|
||||
case MPShuffleTypeOff:
|
||||
modeIndex = 0;
|
||||
break;
|
||||
case MPShuffleTypeItems:
|
||||
modeIndex = 1;
|
||||
break;
|
||||
// MPShuffleTypeCollections
|
||||
default:
|
||||
modeIndex = 2;
|
||||
break;
|
||||
}
|
||||
[handlerChannel invokeMethod:@"setShuffleMode" arguments:@{
|
||||
@"shuffleMode":@(modeIndex)
|
||||
}];
|
||||
return MPRemoteCommandHandlerStatusSuccess;
|
||||
}
|
||||
|
||||
- (void) dealloc {
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,50 @@
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#if TARGET_OS_OSX
|
||||
#import <FlutterMacOS/FlutterMacOS.h>
|
||||
#else
|
||||
#import <Flutter/Flutter.h>
|
||||
#endif
|
||||
|
||||
@interface AudioServicePlugin : NSObject<FlutterPlugin>
|
||||
|
||||
@property (readonly, nonatomic) FlutterMethodChannel *channel;
|
||||
|
||||
@end
|
||||
|
||||
enum AudioProcessingState {
|
||||
ApsIdle,
|
||||
ApsLoading,
|
||||
ApsBuffering,
|
||||
ApsReady,
|
||||
ApsCompleted,
|
||||
ApsError
|
||||
};
|
||||
|
||||
enum MediaAction {
|
||||
AStop,
|
||||
APause,
|
||||
APlay,
|
||||
ARewind,
|
||||
ASkipToPrevious,
|
||||
ASkipToNext,
|
||||
AFastForward,
|
||||
ASetRating,
|
||||
ASeekTo,
|
||||
APlayPause,
|
||||
APlayFromMediaId,
|
||||
APlayFromSearch,
|
||||
ASkipToQueueItem,
|
||||
APlayFromUri,
|
||||
APrepare,
|
||||
APrepareFromMediaId,
|
||||
APrepareFromSearch,
|
||||
APrepareFromUri,
|
||||
ASetRepeatMode,
|
||||
ASetShuffleModeEnabled, // deprecated
|
||||
ASetCaptioningEnabled,
|
||||
ASetShuffleMode,
|
||||
// iOS/macOS-specific
|
||||
ASeekBackward,
|
||||
ASeekForward,
|
||||
ASetSpeed,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
name: audio_service
|
||||
description: Flutter plugin to play audio in the background while the screen is off.
|
||||
version: 0.18.19
|
||||
repository: https://github.com/ryanheise/audio_service/tree/minor/audio_service
|
||||
issue_tracker: https://github.com/ryanheise/audio_service/issues
|
||||
topics:
|
||||
- audio
|
||||
- background
|
||||
|
||||
environment:
|
||||
sdk: ^3.6.0
|
||||
flutter: ">=3.27.0"
|
||||
|
||||
dependencies:
|
||||
# Use these deps for local development
|
||||
# audio_service_platform_interface:
|
||||
# path: ../audio_service_platform_interface
|
||||
# audio_service_web:
|
||||
# path: ../audio_service_web
|
||||
|
||||
# Use these deps when pushing to origin (for the benefit of testers)
|
||||
# audio_service_platform_interface:
|
||||
# git:
|
||||
# url: https://github.com/ryanheise/audio_service.git
|
||||
# ref: one-isolate
|
||||
# path: audio_service_platform_interface
|
||||
# audio_service_web:
|
||||
# git:
|
||||
# url: https://github.com/ryanheise/audio_service.git
|
||||
# ref: one-isolate
|
||||
# path: audio_service_web
|
||||
|
||||
# Use these deps when publishing.
|
||||
audio_service_platform_interface: ^0.1.3
|
||||
audio_service_web: ^0.1.4
|
||||
|
||||
audio_session: '>=0.1.25 <0.3.0'
|
||||
rxdart: '>=0.26.0 <0.29.0'
|
||||
flutter_cache_manager: ^3.3.1
|
||||
clock: ^1.1.0
|
||||
js: '>=0.6.3 <0.8.0'
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_web_plugins:
|
||||
sdk: flutter
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
mockito: ^5.0.0
|
||||
fake_async: ^1.2.0
|
||||
flutter_lints: ^3.0.1
|
||||
|
||||
flutter:
|
||||
plugin:
|
||||
platforms:
|
||||
android:
|
||||
package: com.ryanheise.audioservice
|
||||
pluginClass: AudioServicePlugin
|
||||
ios:
|
||||
pluginClass: AudioServicePlugin
|
||||
sharedDarwinSource: true
|
||||
macos:
|
||||
pluginClass: AudioServicePlugin
|
||||
sharedDarwinSource: true
|
||||
web:
|
||||
default_package: audio_service_web
|
||||