Dart/Android implementation

This commit is contained in:
Ryan Heise 2019-11-28 16:16:54 +11:00
parent 64dcdc8e9c
commit 0574c041c5
10 changed files with 688 additions and 51 deletions

View File

@ -1,3 +1,3 @@
## 0.0.1
* TODO: Describe initial release.
* Initial release with Android implementation first.

View File

@ -1,14 +1,12 @@
# audio_player
A new flutter plugin project.
A new Flutter audio player plugin designed to support background playback with [audio_service](https://pub.dev/packages/audio_service)
## Getting Started
## Features
This project is a starting point for a Flutter
[plug-in package](https://flutter.dev/developing-packages/),
a specialized package that includes platform-specific implementation code for
Android and/or iOS.
* Plays audio from streams, files and assets.
* Broadcasts state changes helpful in streaming apps such as `buffering` and `connecting` in addition to the typical `playing`, `paused` and `stopped` states.
* Control audio playback via standard operations: play, pause, stop, setVolume, seek.
* Compatible with [audio_service](https://pub.dev/packages/audio_service) to support full background playback, queue management, and controlling playback from the lock screen, notifications and headset buttons.
For help getting started with Flutter, view our
[online documentation](https://flutter.dev/docs), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
The initial release is for Android. iOS is the next priority.

View File

@ -0,0 +1,259 @@
package com.ryanheise.audio_player;
import android.media.MediaPlayer;
import android.media.MediaTimestamp;
import io.flutter.plugin.common.EventChannel;
import io.flutter.plugin.common.EventChannel.EventSink;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
import java.util.List;
import java.io.IOException;
import android.os.Handler;
import java.util.ArrayList;
import io.flutter.plugin.common.PluginRegistry.Registrar;
public class AudioPlayer implements MethodCallHandler, MediaPlayer.OnCompletionListener {
private final Registrar registrar;
private final MethodChannel methodChannel;
private final EventChannel eventChannel;
private EventSink eventSink;
private final Handler handler = new Handler();
private Runnable endDetector;
private final Runnable positionObserver = new Runnable() {
@Override
public void run() {
if (state != PlaybackState.playing && state != PlaybackState.buffering)
return;
if (eventSink != null) {
checkForDiscontinuity();
}
handler.postDelayed(this, 200);
}
};
private final long id;
private final MediaPlayer player;
private PlaybackState state;
private PlaybackState stateBeforeSeek;
private long updateTime;
private int updatePosition;
private Integer seekPos;
public AudioPlayer(final Registrar registrar, final long id) {
this.registrar = registrar;
this.id = id;
methodChannel = new MethodChannel(registrar.messenger(), "com.ryanheise.audio_player.methods." + id);
methodChannel.setMethodCallHandler(this);
eventChannel = new EventChannel(registrar.messenger(), "com.ryanheise.audio_player.events." + id);
eventChannel.setStreamHandler(new EventChannel.StreamHandler() {
@Override
public void onListen(final Object arguments, final EventSink eventSink) {
AudioPlayer.this.eventSink = eventSink;
}
@Override
public void onCancel(final Object arguments) {
eventSink = null;
}
});
state = PlaybackState.none;
player = new MediaPlayer();
player.setOnCompletionListener(this);
}
private void checkForDiscontinuity() {
// TODO: Consider using player.setOnMediaTimeDiscontinuityListener()
// when available in SDK. (Added in API level 28)
final long now = System.currentTimeMillis();
final int position = getCurrentPosition();
final long timeSinceLastUpdate = now - updateTime;
final long expectedPosition = updatePosition + timeSinceLastUpdate;
final long drift = position - expectedPosition;
// Update if we've drifted or just started observing
if (updateTime == 0L) {
broadcastPlayerState();
} else if (drift < -100) {
System.out.println("time discontinuity detected: " + drift);
setPlaybackState(PlaybackState.buffering);
} else if (state == PlaybackState.buffering) {
setPlaybackState(PlaybackState.playing);
}
}
@Override
public void onCompletion(final MediaPlayer mp) {
setPlaybackState(PlaybackState.stopped);
}
@Override
public void onMethodCall(final MethodCall call, final Result result) {
final List<?> args = (List<?>)call.arguments;
try {
switch (call.method) {
case "setUrl":
setUrl((String)args.get(0), result);
break;
case "play":
play((Integer)args.get(0));
result.success(null);
break;
case "pause":
pause();
result.success(null);
break;
case "stop":
stop();
result.success(null);
break;
case "setVolume":
setVolume((Double)args.get(0));
result.success(null);
break;
case "seek":
seek((Integer)args.get(0), result);
break;
case "dispose":
dispose();
result.success(null);
break;
default:
result.notImplemented();
break;
}
} catch (IllegalStateException e) {
e.printStackTrace();
result.error("Illegal state", null, null);
} catch (Exception e) {
e.printStackTrace();
result.error("Error", null, null);
}
}
private void broadcastPlayerState() {
final ArrayList<Object> event = new ArrayList<Object>();
// state
event.add(state.ordinal());
// updatePosition
event.add(updatePosition = getCurrentPosition());
// updateTime
event.add(updateTime = System.currentTimeMillis());
eventSink.success(event);
}
private int getCurrentPosition() {
if (state == PlaybackState.none || state == PlaybackState.connecting) {
return 0;
} else if (seekPos != null) {
return seekPos;
} else {
return player.getCurrentPosition();
}
}
private void setPlaybackState(final PlaybackState state) {
final PlaybackState oldState = this.state;
this.state = state;
if (oldState != PlaybackState.playing && state == PlaybackState.playing) {
startObservingPosition();
}
broadcastPlayerState();
}
public void setUrl(final String url, final Result result) throws IOException {
setPlaybackState(PlaybackState.connecting);
player.reset();
player.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
@Override
public void onPrepared(final MediaPlayer mp) {
setPlaybackState(PlaybackState.stopped);
result.success(mp.getDuration());
}
});
player.setDataSource(url);
player.prepareAsync();
}
public void play(final Integer untilPosition) {
// TODO: dynamically adjust the lag.
final int lag = 6;
final int start = getCurrentPosition();
if (untilPosition != null && untilPosition <= start) {
return;
}
player.start();
setPlaybackState(PlaybackState.playing);
if (endDetector != null) {
handler.removeCallbacks(endDetector);
}
if (untilPosition != null) {
final int duration = Math.max(0, untilPosition - start - lag);
handler.postDelayed(new Runnable() {
@Override
public void run() {
final int position = getCurrentPosition();
if (position > untilPosition - 20) {
pause();
} else {
final int duration = Math.max(0, untilPosition - position - lag);
handler.postDelayed(this, duration);
}
}
}, duration);
}
}
public void pause() {
player.pause();
setPlaybackState(PlaybackState.paused);
}
public void stop() {
player.pause();
player.seekTo(0);
setPlaybackState(PlaybackState.stopped);
}
public void setVolume(final double volume) {
player.setVolume((float)volume, (float)volume);
}
public void seek(final int position, final Result result) {
stateBeforeSeek = state;
seekPos = position;
handler.removeCallbacks(positionObserver);
setPlaybackState(PlaybackState.buffering);
player.setOnSeekCompleteListener(new MediaPlayer.OnSeekCompleteListener() {
@Override
public void onSeekComplete(final MediaPlayer mp) {
seekPos = null;
setPlaybackState(stateBeforeSeek);
stateBeforeSeek = null;
result.success(null);
player.setOnSeekCompleteListener(null);
}
});
player.seekTo(position);
}
public void dispose() {
player.release();
setPlaybackState(PlaybackState.none);
}
private void startObservingPosition() {
handler.removeCallbacks(positionObserver);
handler.post(positionObserver);
}
enum PlaybackState {
none,
stopped,
paused,
playing,
buffering,
connecting
}
}

View File

@ -10,16 +10,27 @@ import io.flutter.plugin.common.PluginRegistry.Registrar;
public class AudioPlayerPlugin implements MethodCallHandler {
/** Plugin registration. */
public static void registerWith(Registrar registrar) {
final MethodChannel channel = new MethodChannel(registrar.messenger(), "audio_player");
channel.setMethodCallHandler(new AudioPlayerPlugin());
final MethodChannel channel = new MethodChannel(registrar.messenger(), "com.ryanheise.audio_player.methods");
channel.setMethodCallHandler(new AudioPlayerPlugin(registrar));
}
private Registrar registrar;
public AudioPlayerPlugin(Registrar registrar) {
this.registrar = registrar;
}
@Override
public void onMethodCall(MethodCall call, Result result) {
if (call.method.equals("getPlatformVersion")) {
result.success("Android " + android.os.Build.VERSION.RELEASE);
} else {
result.notImplemented();
}
switch (call.method) {
case "init":
long id = (Long)call.arguments;
new AudioPlayer(registrar, id);
result.success(null);
break;
default:
result.notImplemented();
break;
}
}
}

View File

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'dart:async';
@ -12,32 +14,19 @@ class MyApp extends StatefulWidget {
}
class _MyAppState extends State<MyApp> {
String _platformVersion = 'Unknown';
final AudioPlayer _player = AudioPlayer();
@override
void initState() {
super.initState();
initPlatformState();
_player.setUrl(
"https://s3.amazonaws.com/scifri-episodes/scifri20181123-episode.mp3");
}
// Platform messages are asynchronous, so we initialize in an async method.
Future<void> initPlatformState() async {
String platformVersion;
// Platform messages may fail, so we use a try/catch PlatformException.
try {
platformVersion = await AudioPlayer.platformVersion;
} on PlatformException {
platformVersion = 'Failed to get platform version.';
}
// If the widget was removed from the tree while the asynchronous platform
// message was in flight, we want to discard the reply rather than calling
// setState to update our non-existent appearance.
if (!mounted) return;
setState(() {
_platformVersion = platformVersion;
});
@override
void dispose() {
_player.dispose();
super.dispose();
}
@override
@ -45,12 +34,121 @@ class _MyAppState extends State<MyApp> {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Plugin example app'),
title: const Text('Audio Player Demo'),
),
body: Center(
child: Text('Running on: $_platformVersion\n'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("Science Friday"),
Text("Science Friday and WNYC Studios"),
StreamBuilder<AudioPlaybackState>(
stream: _player.playbackStateStream,
builder: (context, snapshot) {
final state = snapshot.data;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (state == AudioPlaybackState.playing)
IconButton(
icon: Icon(Icons.pause),
iconSize: 64.0,
onPressed: _player.pause,
)
else if (state == AudioPlaybackState.buffering ||
state == AudioPlaybackState.connecting)
Container(
margin: EdgeInsets.all(8.0),
width: 64.0,
height: 64.0,
child: CircularProgressIndicator(),
)
else
IconButton(
icon: Icon(Icons.play_arrow),
iconSize: 64.0,
onPressed: _player.play,
),
IconButton(
icon: Icon(Icons.stop),
iconSize: 64.0,
onPressed: state == AudioPlaybackState.stopped ||
state == AudioPlaybackState.none
? null
: _player.stop,
),
],
);
},
),
StreamBuilder<Duration>(
stream: _player.durationStream,
builder: (context, snapshot) {
final duration = snapshot.data ?? Duration.zero;
return StreamBuilder<Duration>(
stream: _player.getPositionStream(),
builder: (context, snapshot) {
final position = snapshot.data ?? Duration.zero;
return SeekBar(
duration: duration,
position: position,
onChangeEnd: (newPosition) {
_player.seek(newPosition);
},
);
},
);
},
),
],
),
),
),
);
}
}
class SeekBar extends StatefulWidget {
final Duration duration;
final Duration position;
final ValueChanged<Duration> onChanged;
final ValueChanged<Duration> onChangeEnd;
SeekBar({
@required this.duration,
@required this.position,
this.onChanged,
this.onChangeEnd,
});
@override
_SeekBarState createState() => _SeekBarState();
}
class _SeekBarState extends State<SeekBar> {
double _dragValue;
@override
Widget build(BuildContext context) {
return Slider(
min: 0.0,
max: widget.duration.inMilliseconds.toDouble(),
value: _dragValue ?? widget.position.inMilliseconds.toDouble(),
onChanged: (value) {
setState(() {
_dragValue = value;
});
if (widget.onChanged != null) {
widget.onChanged(Duration(milliseconds: value.round()));
}
},
onChangeEnd: (value) {
_dragValue = null;
if (widget.onChangeEnd != null) {
widget.onChangeEnd(Duration(milliseconds: value.round()));
}
},
);
}
}

View File

@ -74,6 +74,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.6.4"
path_provider:
dependency: transitive
description:
name: path_provider
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.5"
pedantic:
dependency: transitive
description:
@ -81,6 +88,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.0+1"
platform:
dependency: transitive
description:
name: platform
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1"
quiver:
dependency: transitive
description:
@ -88,6 +102,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.5"
rxdart:
dependency: transitive
description:
name: rxdart
url: "https://pub.dartlang.org"
source: hosted
version: "0.22.6"
sky_engine:
dependency: transitive
description: flutter
@ -151,3 +172,4 @@ packages:
version: "2.0.8"
sdks:
dart: ">=2.2.2 <3.0.0"
flutter: ">=1.9.1+hotfix.5 <2.0.0"

View File

@ -2,9 +2,6 @@ name: audio_player_example
description: Demonstrates how to use the audio_player plugin.
publish_to: 'none'
environment:
sdk: ">=2.1.0 <3.0.0"
dependencies:
flutter:
sdk: flutter

View File

@ -1,13 +1,240 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:rxdart/rxdart.dart';
/// An object to manage playing audio from a URL, a locale file or an asset.
///
/// ```
/// final player = AudioPlayer();
/// await player.setUrl('https://foo.com/bar.mp3');
/// await player.play();
/// await player.pause();
/// await player.play(untilPosition: Duration(minutes: 1));
/// await player.stop()
/// await player.setUrl('https://foo.com/baz.mp3');
/// await player.seek(Duration(minutes: 5));
/// await player.play();
/// await player.stop();
/// await player.dispose();
/// ```
///
/// You must call [dispose] to release the resources used by this player,
/// including any temporary files created to cache assets.
///
/// The [AudioPlayer] instance transitions through different states as follows:
///
/// * [AudioPlaybackState.none]: immediately after instantiation.
/// * [AudioPlaybackState.stopped]: eventually after [setUrl], [setFilePath] or
/// [setAsset] completes, immediately after [stop], and immediately after
/// playback naturally reaches the end of the media.
/// * [AudioPlaybackState.paused]: after [pause] and after reaching the end of
/// the requested [play] segment.
/// * [AudioPlaybackState.playing]: after [play] and after sufficiently
/// buffering during normal playback.
/// * [AudioPlaybackState.buffering]: immediately after a seek request and
/// during normal playback when the next buffer is not ready to be played.
/// * [AudioPlaybackState.connecting]: immediately after [setUrl],
/// [setFilePath] and [setAsset] while waiting for the media to load.
///
/// Additionally, after a [seek] request completes, the state will return to
/// whatever state the player was in prior to the seek request.
class AudioPlayer {
static const MethodChannel _channel =
const MethodChannel('audio_player');
static final _mainChannel =
MethodChannel('com.ryanheise.audio_player.methods');
static Future<String> get platformVersion async {
final String version = await _channel.invokeMethod('getPlatformVersion');
return version;
static Future<MethodChannel> _createChannel(int id) async {
await _mainChannel.invokeMethod('init', id);
return MethodChannel('com.ryanheise.audio_player.methods.$id');
}
final Future<MethodChannel> _channel;
final int _id;
Future<Duration> _durationFuture;
final _durationSubject = BehaviorSubject<Duration>();
AudioPlayerState _audioPlayerState;
Stream<AudioPlayerState> _eventChannelStream;
StreamSubscription<AudioPlayerState> _eventChannelStreamSubscription;
final _playerStateSubject = BehaviorSubject<AudioPlayerState>();
final _playbackStateSubject = BehaviorSubject<AudioPlaybackState>();
/// Creates an [AudioPlayer].
factory AudioPlayer() =>
AudioPlayer._internal(DateTime.now().microsecondsSinceEpoch);
AudioPlayer._internal(this._id) : _channel = _createChannel(_id) {
_eventChannelStream = EventChannel('com.ryanheise.audio_player.events.$_id')
.receiveBroadcastStream()
.map((data) => _audioPlayerState = AudioPlayerState(
state: AudioPlaybackState.values[data[0]],
updatePosition: Duration(milliseconds: data[1]),
updateTime: Duration(milliseconds: data[2]),
));
_eventChannelStreamSubscription =
_eventChannelStream.listen(_playerStateSubject.add);
_playbackStateSubject
.addStream(playerStateStream.map((state) => state.state).distinct());
}
/// The duration of any media set via [setUrl], [setFilePath] or [setAsset],
/// or null otherwise.
Future<Duration> get durationFuture => _durationFuture;
/// The duration of any media set via [setUrl], [setFilePath] or [setAsset].
Stream<Duration> get durationStream => _durationSubject.stream;
/// The current [AudioPlayerState].
AudioPlayerState get playerState => _audioPlayerState;
/// The current [AudioPlayerState].
Stream<AudioPlayerState> get playerStateStream => _playerStateSubject.stream;
/// The current [AudioPlaybackState].
Stream<AudioPlaybackState> get playbackStateStream =>
_playbackStateSubject.stream;
/// A stream periodically tracking the current position of this player.
Stream<Duration> getPositionStream(
[final Duration period = const Duration(milliseconds: 200)]) =>
Observable.combineLatest2<AudioPlayerState, void, Duration>(
playerStateStream,
Observable.periodic(period),
(state, _) => state.position);
/// Loads audio media from a URL and returns the duration of that audio.
Future<Duration> setUrl(final String url) async {
_durationFuture =
_invokeMethod('setUrl', [url]).then((ms) => Duration(milliseconds: ms));
final duration = await _durationFuture;
_durationSubject.add(duration);
return duration;
}
/// Loads audio media from a file and returns the duration of that audio.
Future<Duration> setFilePath(final String filePath) => setUrl('file://$filePath');
/// Loads audio media from an asset and returns the duration of that audio.
Future<Duration> setAsset(final String assetPath) async {
final file = await _cacheFile;
if (!file.existsSync()) {
await file.create(recursive: true);
}
await file.writeAsBytes(
(await rootBundle.load(assetPath)).buffer.asUint8List());
return await setFilePath(file.path);
}
Future<File> get _cacheFile async => File(p.join((await getTemporaryDirectory()).path, 'audio_player_asset_cache', '$_id'));
/// Plays the currently loaded media from the current position. It is legal
/// to invoke this method only from one of the following states:
///
/// * [AudioPlaybackState.stopped]
/// * [AudioPlaybackState.paused]
Future<void> play({final Duration untilPosition}) async {
StreamSubscription subscription;
Completer completer = Completer();
subscription = playbackStateStream
.skip(1)
.where((state) =>
state == AudioPlaybackState.paused ||
state == AudioPlaybackState.stopped)
.listen((state) {
subscription.cancel();
completer.complete();
});
await _invokeMethod('play', [untilPosition?.inMilliseconds]);
await completer.future;
}
/// Pauses the currently playing media. It is legal to invoke this method
/// only from the following states:
///
/// * [AudioPlaybackState.playing]
/// * [AudioPlaybackState.buffering]
Future<void> pause() async {
await _invokeMethod('pause');
}
/// Stops the currently playing media such that the next [play] invocation
/// will start from position 0. It is legal to invoke this method from any
/// state except for:
///
/// * [AudioPlaybackState.none]
/// * [AudioPlaybackState.stopped]
Future<void> stop() async {
await _invokeMethod('stop');
}
/// Sets the volume of this player, where 1.0 is normal volume.
Future<void> setVolume(final double volume) async {
await _invokeMethod('setVolume', [volume]);
}
/// Seeks to a particular position. It is legal to invoke this method
/// from any state except for [AudioPlaybackState.none].
Future<void> seek(final Duration position) async {
await _invokeMethod('seek', [position.inMilliseconds]);
}
/// Release all resources associated with this player. You must invoke
/// this after you are done with the player.
Future<void> dispose() async {
if ((await _cacheFile).existsSync()) {
(await _cacheFile).deleteSync();
}
await _invokeMethod('dispose');
await _durationSubject.close();
await _eventChannelStreamSubscription.cancel();
await _playerStateSubject.close();
}
Future<dynamic> _invokeMethod(String method, [dynamic args]) async =>
(await _channel).invokeMethod(method, args);
}
/// Encapsulates the playback state and current position of the player.
class AudioPlayerState {
/// The current playback state.
final AudioPlaybackState state;
/// When the last time a position discontinuity happened, as measured in time
/// since the epoch.
final Duration updateTime;
/// The position at [updateTime].
final Duration updatePosition;
AudioPlayerState({
@required this.state,
@required this.updateTime,
@required this.updatePosition,
});
/// The current position of the player.
Duration get position => state == AudioPlaybackState.playing
? updatePosition +
(Duration(milliseconds: DateTime.now().millisecondsSinceEpoch) -
updateTime)
: updatePosition;
}
/// Enumerates the different playback states of a player.
enum AudioPlaybackState {
none,
stopped,
paused,
playing,
buffering,
connecting,
}

View File

@ -54,12 +54,19 @@ packages:
source: hosted
version: "1.1.7"
path:
dependency: transitive
dependency: "direct main"
description:
name: path
url: "https://pub.dartlang.org"
source: hosted
version: "1.6.4"
path_provider:
dependency: "direct main"
description:
name: path_provider
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.5"
pedantic:
dependency: transitive
description:
@ -67,6 +74,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.0+1"
platform:
dependency: transitive
description:
name: platform
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1"
quiver:
dependency: transitive
description:
@ -74,6 +88,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.5"
rxdart:
dependency: "direct main"
description:
name: rxdart
url: "https://pub.dartlang.org"
source: hosted
version: "0.22.6"
sky_engine:
dependency: transitive
description: flutter
@ -137,3 +158,4 @@ packages:
version: "2.0.8"
sdks:
dart: ">=2.2.2 <3.0.0"
flutter: ">=1.9.1+hotfix.5 <2.0.0"

View File

@ -1,13 +1,16 @@
name: audio_player
description: A new flutter plugin project.
description: Flutter plugin to play audio from streams, files and assets. Works with audio_service to play audio in the background.
version: 0.0.1
author:
homepage:
author: Ryan Heise <ryan@ryanheise.com>
homepage: https://pub.dev/packages/audio_service
environment:
sdk: ">=2.1.0 <3.0.0"
dependencies:
rxdart: ^0.22.6
path: ^1.6.4
path_provider: ^1.4.5
flutter:
sdk: flutter