Dart/Android implementation
This commit is contained in:
parent
64dcdc8e9c
commit
0574c041c5
|
@ -1,3 +1,3 @@
|
||||||
## 0.0.1
|
## 0.0.1
|
||||||
|
|
||||||
* TODO: Describe initial release.
|
* Initial release with Android implementation first.
|
||||||
|
|
16
README.md
16
README.md
|
@ -1,14 +1,12 @@
|
||||||
# audio_player
|
# 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
|
* Plays audio from streams, files and assets.
|
||||||
[plug-in package](https://flutter.dev/developing-packages/),
|
* Broadcasts state changes helpful in streaming apps such as `buffering` and `connecting` in addition to the typical `playing`, `paused` and `stopped` states.
|
||||||
a specialized package that includes platform-specific implementation code for
|
* Control audio playback via standard operations: play, pause, stop, setVolume, seek.
|
||||||
Android and/or iOS.
|
* 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
|
The initial release is for Android. iOS is the next priority.
|
||||||
[online documentation](https://flutter.dev/docs), which offers tutorials,
|
|
||||||
samples, guidance on mobile development, and a full API reference.
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,16 +10,27 @@ import io.flutter.plugin.common.PluginRegistry.Registrar;
|
||||||
public class AudioPlayerPlugin implements MethodCallHandler {
|
public class AudioPlayerPlugin implements MethodCallHandler {
|
||||||
/** Plugin registration. */
|
/** Plugin registration. */
|
||||||
public static void registerWith(Registrar registrar) {
|
public static void registerWith(Registrar registrar) {
|
||||||
final MethodChannel channel = new MethodChannel(registrar.messenger(), "audio_player");
|
final MethodChannel channel = new MethodChannel(registrar.messenger(), "com.ryanheise.audio_player.methods");
|
||||||
channel.setMethodCallHandler(new AudioPlayerPlugin());
|
channel.setMethodCallHandler(new AudioPlayerPlugin(registrar));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Registrar registrar;
|
||||||
|
|
||||||
|
public AudioPlayerPlugin(Registrar registrar) {
|
||||||
|
this.registrar = registrar;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onMethodCall(MethodCall call, Result result) {
|
public void onMethodCall(MethodCall call, Result result) {
|
||||||
if (call.method.equals("getPlatformVersion")) {
|
switch (call.method) {
|
||||||
result.success("Android " + android.os.Build.VERSION.RELEASE);
|
case "init":
|
||||||
} else {
|
long id = (Long)call.arguments;
|
||||||
result.notImplemented();
|
new AudioPlayer(registrar, id);
|
||||||
}
|
result.success(null);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
result.notImplemented();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
@ -12,32 +14,19 @@ class MyApp extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MyAppState extends State<MyApp> {
|
class _MyAppState extends State<MyApp> {
|
||||||
String _platformVersion = 'Unknown';
|
final AudioPlayer _player = AudioPlayer();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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.
|
@override
|
||||||
Future<void> initPlatformState() async {
|
void dispose() {
|
||||||
String platformVersion;
|
_player.dispose();
|
||||||
// Platform messages may fail, so we use a try/catch PlatformException.
|
super.dispose();
|
||||||
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
|
@override
|
||||||
|
@ -45,12 +34,121 @@ class _MyAppState extends State<MyApp> {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Plugin example app'),
|
title: const Text('Audio Player Demo'),
|
||||||
),
|
),
|
||||||
body: Center(
|
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()));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -74,6 +74,13 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.4"
|
version: "1.6.4"
|
||||||
|
path_provider:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.5"
|
||||||
pedantic:
|
pedantic:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -81,6 +88,13 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.8.0+1"
|
version: "1.8.0+1"
|
||||||
|
platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: platform
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.1"
|
||||||
quiver:
|
quiver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -88,6 +102,13 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.5"
|
version: "2.0.5"
|
||||||
|
rxdart:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: rxdart
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.22.6"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
@ -151,3 +172,4 @@ packages:
|
||||||
version: "2.0.8"
|
version: "2.0.8"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=2.2.2 <3.0.0"
|
dart: ">=2.2.2 <3.0.0"
|
||||||
|
flutter: ">=1.9.1+hotfix.5 <2.0.0"
|
||||||
|
|
|
@ -2,9 +2,6 @@ name: audio_player_example
|
||||||
description: Demonstrates how to use the audio_player plugin.
|
description: Demonstrates how to use the audio_player plugin.
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
environment:
|
|
||||||
sdk: ">=2.1.0 <3.0.0"
|
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
|
@ -1,13 +1,240 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
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 {
|
class AudioPlayer {
|
||||||
static const MethodChannel _channel =
|
static final _mainChannel =
|
||||||
const MethodChannel('audio_player');
|
MethodChannel('com.ryanheise.audio_player.methods');
|
||||||
|
|
||||||
static Future<String> get platformVersion async {
|
static Future<MethodChannel> _createChannel(int id) async {
|
||||||
final String version = await _channel.invokeMethod('getPlatformVersion');
|
await _mainChannel.invokeMethod('init', id);
|
||||||
return version;
|
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,
|
||||||
}
|
}
|
||||||
|
|
24
pubspec.lock
24
pubspec.lock
|
@ -54,12 +54,19 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.7"
|
version: "1.1.7"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path
|
name: path
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.4"
|
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:
|
pedantic:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -67,6 +74,13 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.8.0+1"
|
version: "1.8.0+1"
|
||||||
|
platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: platform
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.1"
|
||||||
quiver:
|
quiver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -74,6 +88,13 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.5"
|
version: "2.0.5"
|
||||||
|
rxdart:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: rxdart
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.22.6"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
@ -137,3 +158,4 @@ packages:
|
||||||
version: "2.0.8"
|
version: "2.0.8"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=2.2.2 <3.0.0"
|
dart: ">=2.2.2 <3.0.0"
|
||||||
|
flutter: ">=1.9.1+hotfix.5 <2.0.0"
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
name: audio_player
|
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
|
version: 0.0.1
|
||||||
author:
|
author: Ryan Heise <ryan@ryanheise.com>
|
||||||
homepage:
|
homepage: https://pub.dev/packages/audio_service
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=2.1.0 <3.0.0"
|
sdk: ">=2.1.0 <3.0.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
rxdart: ^0.22.6
|
||||||
|
path: ^1.6.4
|
||||||
|
path_provider: ^1.4.5
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue