From e4789d9cd2564c0f6c44e8c6a4f444a11250566f Mon Sep 17 00:00:00 2001 From: Ryan Heise Date: Tue, 4 Aug 2020 00:16:15 +1000 Subject: [PATCH] New state model, processingState + playing --- README.md | 64 +- .../com/ryanheise/just_audio/AudioPlayer.java | 171 ++--- example/lib/main.dart | 38 +- example/pubspec.lock | 79 +-- lib/just_audio.dart | 636 +++++++++--------- lib/just_audio_web.dart | 197 ++++-- pubspec.lock | 79 +-- 7 files changed, 620 insertions(+), 644 deletions(-) diff --git a/README.md b/README.md index a3e08bc..6ec8b65 100644 --- a/README.md +++ b/README.md @@ -4,26 +4,27 @@ A Flutter plugin to play audio from URLs, files, assets, DASH/HLS streams and pl ## Features -| Feature | Android | iOS | MacOS | Web | -| ------- | :-------: | :-----: | :-----: | :-----: | -| read from URL | ✅ | ✅ | ✅ | ✅ | -| read from file | ✅ | ✅ | ✅ | | -| read from asset | ✅ | ✅ | ✅ | | -| request headers | ✅ | ✅ | ✅ | | -| DASH | ✅ | (untested) | (untested) | (untested) | -| HLS | ✅ | ✅ | (untested) | (untested) | -| play/pause/stop/seek | ✅ | ✅ | ✅ | ✅ | -| set volume | ✅ | ✅ | (untested) | ✅ | -| set speed | ✅ | ✅ | ✅ | ✅ | -| clip audio | ✅ | ✅ | (untested) | ✅ | -| playlists | ✅ | ✅ | (untested) | ✅ | -| looping | ✅ | ✅ | (untested) | ✅ | -| shuffle | ✅ | ✅ | (untested) | ✅ | -| compose audio | ✅ | ✅ | (untested) | ✅ | -| gapless playback | ✅ | ✅ | (untested) | | -| report player errors | ✅ | ✅ | ✅ | ✅ | +| Feature | Android | iOS | MacOS | Web | +| ------- | :-------: | :-----: | :-----: | :-----: | +| read from URL | ✅ | ✅ | ✅ | ✅ | +| read from file | ✅ | ✅ | ✅ | | +| read from asset | ✅ | ✅ | ✅ | | +| request headers | ✅ | ✅ | ✅ | | +| DASH | ✅ | (untested) | (untested) | (untested) | +| HLS | ✅ | ✅ | (untested) | (untested) | +| buffer status/position | ✅ | ✅ | (untested) | ✅ | +| play/pause/seek | ✅ | ✅ | ✅ | ✅ | +| set volume | ✅ | ✅ | (untested) | ✅ | +| set speed | ✅ | ✅ | ✅ | ✅ | +| clip audio | ✅ | ✅ | (untested) | ✅ | +| playlists | ✅ | ✅ | (untested) | ✅ | +| looping | ✅ | ✅ | (untested) | ✅ | +| shuffle | ✅ | ✅ | (untested) | ✅ | +| compose audio | ✅ | ✅ | (untested) | ✅ | +| gapless playback | ✅ | ✅ | (untested) | | +| report player errors | ✅ | ✅ | ✅ | ✅ | -This plugin has been tested on Android and Web, and is being made available for testing on iOS and MacOS. Please consider reporting any bugs you encounter [here](https://github.com/ryanheise/just_audio/issues) or submitting pull requests [here](https://github.com/ryanheise/just_audio/pulls). +This plugin has been tested on Android, iOS and Web, and is being made available for testing on MacOS. Please consider reporting any bugs you encounter [here](https://github.com/ryanheise/just_audio/issues) or submitting pull requests [here](https://github.com/ryanheise/just_audio/pulls). ## Example @@ -40,7 +41,6 @@ Standard controls: player.play(); // Usually you don't want to wait for playback to finish. await player.seek(Duration(seconds: 10)); await player.pause(); -await player.stop(); ``` Clipping audio: @@ -122,28 +122,30 @@ try { Listening to state changes: ```dart -player.playbackStateStream.listen((state) { - switch (state) { +player.playerStateStream.listen((state) { + if (state.playing) ... else ... + switch (state.processingState) { case AudioPlaybackState.none: ... - case AudioPlaybackState.stopped: ... - case AudioPlaybackState.paused: ... - case AudioPlaybackState.playing: ... - case AudioPlaybackState.connecting: ... + case AudioPlaybackState.loading: ... + case AudioPlaybackState.buffering: ... + case AudioPlaybackState.ready: ... case AudioPlaybackState.completed: ... } }); // See also: // - durationStream -// - bufferingStream -// - icyMetadataStream +// - positionStream // - bufferedPositionStream -// - fullPlaybackStateStream -// - playbackEventStream // - currentIndexStream +// - icyMetadataStream +// - playingStream +// - processingStateStream // - loopModeStream // - shuffleModeEnabledStream -// - durationStream +// - volumeStream +// - speedStream +// - playbackEventStream ``` ## Platform specific configuration diff --git a/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java b/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java index 3ffa789..fec11db 100644 --- a/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java +++ b/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java @@ -58,21 +58,19 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met private final EventChannel eventChannel; private EventSink eventSink; - private volatile PlaybackState state; + private ProcessingState processingState; private long updateTime; private long updatePosition; private long bufferedPosition; private long duration; private Long start; private Long end; - private float volume = 1.0f; - private float speed = 1.0f; private Long seekPos; private Result prepareResult; + private Result playResult; private Result seekResult; private boolean seekProcessed; - private boolean buffering; - private boolean justConnected; + private boolean playing; private Map mediaSources = new HashMap(); private IcyInfo icyInfo; private IcyHeaders icyHeaders; @@ -95,14 +93,17 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met bufferedPosition = newBufferedPosition; broadcastPlaybackEvent(); } - if (buffering) { + switch (processingState) { + case buffering: handler.postDelayed(this, 200); - } else if (state == PlaybackState.playing) { - handler.postDelayed(this, 500); - } else if (state == PlaybackState.paused) { - handler.postDelayed(this, 1000); - } else if (justConnected) { - handler.postDelayed(this, 1000); + break; + case ready: + if (playing) { + handler.postDelayed(this, 500); + } else { + handler.postDelayed(this, 1000); + } + break; } } }; @@ -127,7 +128,7 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met eventSink = null; } }); - state = PlaybackState.none; + processingState = ProcessingState.none; } private void startWatchingBuffer() { @@ -198,31 +199,31 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met case Player.STATE_READY: if (prepareResult != null) { duration = getDuration(); - justConnected = true; - transition(PlaybackState.stopped); + transition(ProcessingState.ready); prepareResult.success(duration); prepareResult = null; + } else { + transition(ProcessingState.ready); } if (seekProcessed) { completeSeek(); } break; - case Player.STATE_ENDED: - if (state != PlaybackState.completed) { - player.setPlayWhenReady(false); - transition(PlaybackState.completed); - } - break; - } - final boolean buffering = playbackState == Player.STATE_BUFFERING; - // don't notify buffering if (buffering && state == stopped) - final boolean notifyBuffering = !buffering || state != PlaybackState.stopped; - if (notifyBuffering && (buffering != this.buffering)) { - this.buffering = buffering; - broadcastPlaybackEvent(); - if (buffering) { + case Player.STATE_BUFFERING: + if (processingState != ProcessingState.buffering) { + transition(ProcessingState.buffering); startWatchingBuffer(); } + break; + case Player.STATE_ENDED: + if (processingState != ProcessingState.completed) { + transition(ProcessingState.completed); + } + if (playResult != null) { + playResult.success(null); + playResult = null; + } + break; } } @@ -275,16 +276,12 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met load(getAudioSource(args.get(0)), result); break; case "play": - play(); - result.success(null); + play(result); break; case "pause": pause(); result.success(null); break; - case "stop": - stop(result); - break; case "setVolume": setVolume((float) ((double) ((Double) args.get(0)))); result.success(null); @@ -497,22 +494,19 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met } private void load(final MediaSource mediaSource, final Result result) { - justConnected = false; - switch (state) { + switch (processingState) { case none: break; - case connecting: + case loading: abortExistingConnection(); player.stop(); - player.setPlayWhenReady(false); break; default: player.stop(); - player.setPlayWhenReady(false); break; } prepareResult = result; - transition(PlaybackState.connecting); + transition(ProcessingState.loading); if (player.getShuffleModeEnabled()) { setShuffleOrder(mediaSource, 0); } @@ -530,8 +524,7 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met private void broadcastPlaybackEvent() { final Map event = new HashMap(); - event.put("state", state.ordinal()); - event.put("buffering", buffering); + event.put("processingState", processingState.ordinal()); event.put("updatePosition", updatePosition = getCurrentPosition()); event.put("updateTime", updateTime = System.currentTimeMillis()); event.put("bufferedPosition", Math.max(updatePosition, bufferedPosition)); @@ -566,7 +559,7 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met } private long getCurrentPosition() { - if (state == PlaybackState.none || state == PlaybackState.connecting) { + if (processingState == ProcessingState.none || processingState == ProcessingState.loading) { return 0; } else if (seekPos != null && seekPos != C.TIME_UNSET) { return seekPos; @@ -576,7 +569,7 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met } private long getDuration() { - if (state == PlaybackState.none || state == PlaybackState.connecting) { + if (processingState == ProcessingState.none || processingState == ProcessingState.loading) { return C.TIME_UNSET; } else { return player.getDuration(); @@ -594,9 +587,8 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met } } - private void transition(final PlaybackState newState) { - final PlaybackState oldState = state; - state = newState; + private void transition(final ProcessingState newState) { + processingState = newState; broadcastPlaybackEvent(); } @@ -610,70 +602,35 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met return filename.replaceAll("^.*\\.", "").toLowerCase(); } - public void play() { - switch (state) { - case playing: - break; - case stopped: - case completed: - case paused: - justConnected = false; - transition(PlaybackState.playing); - startWatchingBuffer(); - player.setPlayWhenReady(true); - break; - default: - throw new IllegalStateException( - "Cannot call play from connecting/none states (" + state + ")"); + public void play(Result result) { + if (player.getPlayWhenReady()) return; + if (playResult != null) { + result.success(null); + } else { + playResult = result; + } + startWatchingBuffer(); + player.setPlayWhenReady(true); + if (processingState == ProcessingState.completed && playResult != null) { + playResult.success(null); + playResult = null; } } public void pause() { - switch (state) { - case paused: - break; - case playing: - player.setPlayWhenReady(false); - transition(PlaybackState.paused); - break; - default: - throw new IllegalStateException( - "Can call pause only from playing and buffering states (" + state + ")"); - } - } - - public void stop(final Result result) { - switch (state) { - case stopped: - result.success(null); - break; - case connecting: - abortExistingConnection(); - buffering = false; - transition(PlaybackState.stopped); - result.success(null); - break; - case completed: - case playing: - case paused: - abortSeek(); - player.setPlayWhenReady(false); - transition(PlaybackState.stopped); - player.seekTo(0L); - result.success(null); - break; - default: - throw new IllegalStateException("Cannot call stop from none state"); + if (!player.getPlayWhenReady()) return; + player.setPlayWhenReady(false); + if (playResult != null) { + playResult.success(null); + playResult = null; } } public void setVolume(final float volume) { - this.volume = volume; player.setVolume(volume); } public void setSpeed(final float speed) { - this.speed = speed; player.setPlaybackParameters(new PlaybackParameters(speed)); broadcastPlaybackEvent(); } @@ -690,8 +647,8 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met } public void seek(final long position, final Result result, final Integer index) { - if (state == PlaybackState.none || state == PlaybackState.connecting) { - throw new IllegalStateException("Cannot call seek from none none/connecting states"); + if (processingState == ProcessingState.none || processingState == ProcessingState.loading) { + return; } abortSeek(); seekPos = position; @@ -708,8 +665,7 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met if (player != null) { player.release(); player = null; - buffering = false; - transition(PlaybackState.none); + transition(ProcessingState.none); } onDispose.run(); } @@ -731,12 +687,11 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener, Met return (o == null || o instanceof Long) ? (Long)o : new Long(((Integer)o).intValue()); } - enum PlaybackState { + enum ProcessingState { none, - stopped, - paused, - playing, - connecting, + loading, + buffering, + ready, completed } } \ No newline at end of file diff --git a/example/lib/main.dart b/example/lib/main.dart index 3fffdbc..e1a7045 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -101,24 +101,29 @@ class _MyAppState extends State { ); }, ), - StreamBuilder( - stream: _player.fullPlaybackStateStream, + StreamBuilder( + stream: _player.playerStateStream, builder: (context, snapshot) { - final fullState = snapshot.data; - final state = fullState?.state; - final buffering = fullState?.buffering; + final playerState = snapshot.data; + final processingState = playerState?.processingState; + final playing = playerState?.playing; return Row( mainAxisSize: MainAxisSize.min, children: [ - if (state == AudioPlaybackState.connecting || - buffering == true) + if (processingState == ProcessingState.buffering) Container( margin: EdgeInsets.all(8.0), width: 64.0, height: 64.0, child: CircularProgressIndicator(), ) - else if (state == AudioPlaybackState.playing) + else if (playing != true) + IconButton( + icon: Icon(Icons.play_arrow), + iconSize: 64.0, + onPressed: _player.play, + ) + else if (processingState != ProcessingState.completed) IconButton( icon: Icon(Icons.pause), iconSize: 64.0, @@ -126,18 +131,11 @@ class _MyAppState extends State { ) else IconButton( - icon: Icon(Icons.play_arrow), + icon: Icon(Icons.replay), iconSize: 64.0, - onPressed: _player.play, + onPressed: () => + _player.seek(Duration.zero, index: 0), ), - IconButton( - icon: Icon(Icons.stop), - iconSize: 64.0, - onPressed: state == AudioPlaybackState.stopped || - state == AudioPlaybackState.none - ? null - : _player.stop, - ), ], ); }, @@ -148,7 +146,7 @@ class _MyAppState extends State { builder: (context, snapshot) { final duration = snapshot.data ?? Duration.zero; return StreamBuilder( - stream: _player.getPositionStream(), + stream: _player.positionStream, builder: (context, snapshot) { var position = snapshot.data ?? Duration.zero; if (position > duration) { @@ -308,10 +306,10 @@ class _SeekBarState extends State { } }, onChangeEnd: (value) { - _dragValue = null; if (widget.onChangeEnd != null) { widget.onChangeEnd(Duration(milliseconds: value.round())); } + _dragValue = null; }, ); } diff --git a/example/pubspec.lock b/example/pubspec.lock index 8b2fad3..f82b548 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -1,27 +1,13 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - archive: - dependency: transitive - description: - name: archive - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.13" - args: - dependency: transitive - description: - name: args - url: "https://pub.dartlang.org" - source: hosted - version: "1.6.0" async: dependency: transitive description: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.4.1" + version: "2.4.2" boolean_selector: dependency: transitive description: @@ -29,6 +15,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" charcode: dependency: transitive description: @@ -36,13 +29,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.3" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" collection: dependency: transitive description: name: collection url: "https://pub.dartlang.org" source: hosted - version: "1.14.12" + version: "1.14.13" convert: dependency: transitive description: @@ -64,6 +64,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.3" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" file: dependency: transitive description: @@ -86,13 +93,6 @@ packages: description: flutter source: sdk version: "0.0.0" - image: - dependency: transitive - description: - name: image - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.12" intl: dependency: transitive description: @@ -113,7 +113,7 @@ packages: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.6" + version: "0.12.8" meta: dependency: transitive description: @@ -127,7 +127,7 @@ packages: name: path url: "https://pub.dartlang.org" source: hosted - version: "1.6.4" + version: "1.7.0" path_provider: dependency: transitive description: @@ -156,13 +156,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.2" - petitparser: - dependency: transitive - description: - name: petitparser - url: "https://pub.dartlang.org" - source: hosted - version: "2.4.0" platform: dependency: transitive description: @@ -184,13 +177,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.13" - quiver: - dependency: transitive - description: - name: quiver - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.3" rxdart: dependency: "direct main" description: @@ -216,7 +202,7 @@ packages: name: stack_trace url: "https://pub.dartlang.org" source: hosted - version: "1.9.3" + version: "1.9.5" stream_channel: dependency: transitive description: @@ -244,14 +230,14 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.2.15" + version: "0.2.17" typed_data: dependency: transitive description: name: typed_data url: "https://pub.dartlang.org" source: hosted - version: "1.1.6" + version: "1.2.0" uuid: dependency: transitive description: @@ -273,13 +259,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.0" - xml: - dependency: transitive - description: - name: xml - url: "https://pub.dartlang.org" - source: hosted - version: "3.6.1" sdks: - dart: ">=2.6.0 <3.0.0" + dart: ">=2.9.0-14.0.dev <3.0.0" flutter: ">=1.12.13+hotfix.5 <2.0.0" diff --git a/lib/just_audio.dart b/lib/just_audio.dart index b7cdf1e..ff01cc8 100644 --- a/lib/just_audio.dart +++ b/lib/just_audio.dart @@ -17,35 +17,18 @@ final _uuid = Uuid(); /// final player = AudioPlayer(); /// await player.setUrl('https://foo.com/bar.mp3'); /// player.play(); -/// player.pause(); -/// player.play(); -/// await player.stop(); +/// await player.pause(); /// await player.setClip(start: Duration(seconds: 10), end: Duration(seconds: 20)); /// await player.play(); /// await player.setUrl('https://foo.com/baz.mp3'); /// await player.seek(Duration(minutes: 5)); /// player.play(); -/// await player.stop(); +/// await player.pause(); /// 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 and [dispose]. -/// * [AudioPlaybackState.stopped]: eventually after [load] completes, and -/// immediately after [stop]. -/// * [AudioPlaybackState.paused]: after [pause]. -/// * [AudioPlaybackState.playing]: after [play]. -/// * [AudioPlaybackState.connecting]: immediately after [load] while waiting -/// for the media to load. -/// * [AudioPlaybackState.completed]: immediately after playback reaches the -/// end of the media or the end of the clip. -/// -/// 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 final _mainChannel = MethodChannel('com.ryanheise.just_audio.methods'); @@ -75,64 +58,44 @@ class AudioPlayer { } final Future _channel; - - _ProxyHttpServer _proxy; - final String _id; - - Future _durationFuture; - - final _durationSubject = BehaviorSubject(); - - // TODO: also broadcast this event on instantiation. - AudioPlaybackEvent _audioPlaybackEvent = AudioPlaybackEvent( - state: AudioPlaybackState.none, - buffering: false, - updatePosition: Duration.zero, - updateTime: Duration.zero, - bufferedPosition: Duration.zero, - speed: 1.0, - duration: null, - icyMetadata: null, - currentIndex: null, - ); - - Stream _eventChannelStream; - - StreamSubscription _eventChannelStreamSubscription; - - final _playbackEventSubject = BehaviorSubject(); - - final _playbackStateSubject = BehaviorSubject(); - - final _bufferingSubject = BehaviorSubject(); - - final _bufferedPositionSubject = BehaviorSubject(); - - final _icyMetadataSubject = BehaviorSubject(); - - final _fullPlaybackStateSubject = BehaviorSubject(); - - final _currentIndexSubject = BehaviorSubject(); - - final _loopModeSubject = BehaviorSubject(); - - final _shuffleModeEnabledSubject = BehaviorSubject(); - - double _volume = 1.0; - - double _speed = 1.0; - - bool _automaticallyWaitsToMinimizeStalling = true; - + _ProxyHttpServer _proxy; + Stream _eventChannelStream; AudioSource _audioSource; - Map _audioSources = {}; + PlaybackEvent _playbackEvent; + StreamSubscription _eventChannelStreamSubscription; + final _playbackEventSubject = BehaviorSubject(); + Future _durationFuture; + final _durationSubject = BehaviorSubject(); + final _processingStateSubject = BehaviorSubject(); + final _playingSubject = BehaviorSubject.seeded(false); + final _volumeSubject = BehaviorSubject.seeded(1.0); + final _speedSubject = BehaviorSubject.seeded(1.0); + final _bufferedPositionSubject = BehaviorSubject(); + final _icyMetadataSubject = BehaviorSubject(); + final _playerStateSubject = BehaviorSubject(); + final _currentIndexSubject = BehaviorSubject(); + final _loopModeSubject = BehaviorSubject(); + final _shuffleModeEnabledSubject = BehaviorSubject(); + BehaviorSubject _positionSubject; + bool _automaticallyWaitsToMinimizeStalling = true; + /// Creates an [AudioPlayer]. factory AudioPlayer() => AudioPlayer._internal(_uuid.v4()); AudioPlayer._internal(this._id) : _channel = _init(_id) { + _playbackEvent = PlaybackEvent( + processingState: ProcessingState.none, + updatePosition: Duration.zero, + updateTime: DateTime.now(), + bufferedPosition: Duration.zero, + duration: null, + icyMetadata: null, + currentIndex: null, + ); + _playbackEventSubject.add(_playbackEvent); _eventChannelStream = EventChannel('com.ryanheise.just_audio.events.$_id') .receiveBroadcastStream() .map((data) { @@ -142,22 +105,22 @@ class AudioPlayer { ? null : Duration(milliseconds: data['duration']); _durationFuture = Future.value(duration); - _durationSubject.add(duration); - _audioPlaybackEvent = AudioPlaybackEvent( - state: AudioPlaybackState.values[data['state']], - buffering: data['buffering'], + if (duration != _playbackEvent.duration) { + _durationSubject.add(duration); + } + _playbackEvent = PlaybackEvent( + processingState: ProcessingState.values[data['processingState']], updatePosition: Duration(milliseconds: data['updatePosition']), - updateTime: Duration(milliseconds: data['updateTime']), + updateTime: DateTime.fromMillisecondsSinceEpoch(data['updateTime']), bufferedPosition: Duration(milliseconds: data['bufferedPosition']), - speed: _speed, duration: duration, icyMetadata: data['icyMetadata'] == null ? null : IcyMetadata.fromJson(data['icyMetadata']), currentIndex: data['currentIndex'], ); - //print("created event object with state: ${_audioPlaybackEvent.state}"); - return _audioPlaybackEvent; + //print("created event object with state: ${_playbackEvent.state}"); + return _playbackEvent; } catch (e, stacktrace) { print("Error parsing event: $e"); print("$stacktrace"); @@ -165,111 +128,209 @@ class AudioPlayer { } }); _eventChannelStreamSubscription = _eventChannelStream.listen( - _playbackEventSubject.add, - onError: _playbackEventSubject.addError); - _playbackStateSubject.addStream(playbackEventStream - .map((state) => state.state) - .distinct() - .handleError((err, stack) {/* noop */})); - _bufferingSubject.addStream(playbackEventStream - .map((state) => state.buffering) + _playbackEventSubject.add, + onError: _playbackEventSubject.addError, + ); + _processingStateSubject.addStream(playbackEventStream + .map((event) => event.processingState) .distinct() .handleError((err, stack) {/* noop */})); _bufferedPositionSubject.addStream(playbackEventStream - .map((state) => state.bufferedPosition) + .map((event) => event.bufferedPosition) .distinct() .handleError((err, stack) {/* noop */})); _icyMetadataSubject.addStream(playbackEventStream - .map((state) => state.icyMetadata) + .map((event) => event.icyMetadata) .distinct() .handleError((err, stack) {/* noop */})); _currentIndexSubject.addStream(playbackEventStream - .map((state) => state.currentIndex) - .distinct() - .handleError((err, stack) {/* noop */})); - _fullPlaybackStateSubject.addStream(playbackEventStream - .map((event) => FullAudioPlaybackState( - event.state, event.buffering, event.icyMetadata)) + .map((event) => event.currentIndex) .distinct() .handleError((err, stack) {/* noop */})); + _playerStateSubject.addStream( + Rx.combineLatest2( + playingStream, + playbackEventStream, + (playing, event) => PlayerState(playing, event.processingState)) + .distinct() + .handleError((err, stack) {/* noop */})); } - /// The duration of any media loaded via [load], or null if unknown. + /// The latest [PlaybackEvent]. + PlaybackEvent get playbackEvent => _playbackEvent; + + /// A stream of [PlaybackEvent]s. + Stream get playbackEventStream => _playbackEventSubject.stream; + + /// The duration of the current audio or null if unknown. + Duration get duration => _playbackEvent.duration; + + /// The duration of the current audio or null if unknown. Future get durationFuture => _durationFuture; - /// The duration of any media loaded via [load]. + /// The duration of the current audio. Stream get durationStream => _durationSubject.stream; - /// The latest [AudioPlaybackEvent]. - AudioPlaybackEvent get playbackEvent => _audioPlaybackEvent; + /// The current [ProcessingState]. + ProcessingState get processingState => _playbackEvent.processingState; - /// A stream of [AudioPlaybackEvent]s. - Stream get playbackEventStream => - _playbackEventSubject.stream; + /// A stream of [ProcessingState]s. + Stream get processingStateStream => + _processingStateSubject.stream; - /// The current [AudioPlaybackState]. - AudioPlaybackState get playbackState => _audioPlaybackEvent.state; + /// Whether the player is playing. + bool get playing => _playingSubject.value; - /// A stream of [AudioPlaybackState]s. - Stream get playbackStateStream => - _playbackStateSubject.stream; + /// A stream of changing [playing] states. + Stream get playingStream => _playingSubject.stream; - /// A stream broadcasting the current item. - Stream get currentIndexStream => _currentIndexSubject.stream; + /// The current volume of the player. + double get volume => _volumeSubject.value; - /// Whether the player is buffering. - bool get buffering => _audioPlaybackEvent.buffering; + /// A stream of [volume] changes. + Stream get volumeStream => _volumeSubject.stream; - /// The current position of the player. - Duration get position => _audioPlaybackEvent.position; + /// The current speed of the player. + double get speed => _speedSubject.value; - IcyMetadata get icyMetadata => _audioPlaybackEvent.icyMetadata; + /// A stream of current speed values. + Stream get speedStream => _speedSubject.stream; - /// A stream of buffering state changes. - Stream get bufferingStream => _bufferingSubject.stream; - - Stream get icyMetadataStream => _icyMetadataSubject.stream; + /// The position up to which buffered audio is available. + Duration get bufferedPosition => _bufferedPositionSubject.value; /// A stream of buffered positions. Stream get bufferedPositionStream => _bufferedPositionSubject.stream; - /// A stream of [FullAudioPlaybackState]s. - Stream get fullPlaybackStateStream => - _fullPlaybackStateSubject.stream; + /// The latest ICY metadata received through the audio source. + IcyMetadata get icyMetadata => _playbackEvent.icyMetadata; - /// A stream periodically tracking the current position of this player. - Stream getPositionStream( - [final Duration period = const Duration(milliseconds: 200)]) => - Rx.combineLatest2( - playbackEventStream, - // TODO: emit periodically only in playing state. - Stream.periodic(period), - (state, _) => state.position).distinct(); + /// A stream of ICY metadata received through the audio source. + Stream get icyMetadataStream => _icyMetadataSubject.stream; + + /// The current player state containing only the processing and playing + /// states. + PlayerState get playerState => _playerStateSubject.value; + + /// A stream of [PlayerState]s. + Stream get playerStateStream => _playerStateSubject.stream; + + /// The index of the current item. + int get currentIndex => _currentIndexSubject.value; + + /// A stream broadcasting the current item. + Stream get currentIndexStream => _currentIndexSubject.stream; + + /// The current loop mode. + LoopMode get loopMode => _loopModeSubject.value; /// A stream of [LoopMode]s. Stream get loopModeStream => _loopModeSubject.stream; + /// Whether shuffle mode is currently enabled. + bool get shuffleModeEnabled => _shuffleModeEnabledSubject.value; + /// A stream of the shuffle mode status. Stream get shuffleModeEnabledStream => _shuffleModeEnabledSubject.stream; - /// The current volume of the player. - double get volume => _volume; - - /// The current speed of the player. - double get speed => _speed; - /// Whether the player should automatically delay playback in order to /// minimize stalling. (iOS 10.0 or later only) bool get automaticallyWaitsToMinimizeStalling => _automaticallyWaitsToMinimizeStalling; + /// The current position of the player. + Duration get position { + if (playing && processingState == ProcessingState.ready) { + final result = _playbackEvent.updatePosition + + (DateTime.now().difference(_playbackEvent.updateTime)) * speed; + return _playbackEvent.duration == null || + result <= _playbackEvent.duration + ? result + : _playbackEvent.duration; + } else { + return _playbackEvent.updatePosition; + } + } + + /// A stream tracking the current position of this player, suitable for + /// animating a seek bar. To ensure a smooth animation, this stream emits + /// values more frequently on short items where the seek bar moves more + /// quickly, and less frequenly on long items where the seek bar moves more + /// slowly. The interval between each update will be no quicker than once + /// every 16ms and no slower than once every 200ms. + /// + /// See [createPositionStream] for more control over the stream parameters. + Stream get positionStream { + if (_positionSubject == null) { + _positionSubject = BehaviorSubject(); + _positionSubject.addStream(createPositionStream( + steps: 800, + minPeriod: Duration(milliseconds: 16), + maxPeriod: Duration(milliseconds: 11200))); + } + return _positionSubject.stream; + } + + /// Creates a new stream periodically tracking the current position of this + /// player. The stream will aim to emit [steps] position updates from the + /// beginning to the end of the current audio source, at intervals of + /// [duration] / [steps]. This interval will be clipped between [minPeriod] + /// and [maxPeriod]. This stream will not emit values while audio playback is + /// paused or stalled. + /// + /// Note: each time this method is called, a new stream is created. If you + /// intend to use this stream multiple times, you should hold a reference to + /// the returned stream and close it once you are done. + Stream createPositionStream({ + int steps = 800, + Duration minPeriod = const Duration(milliseconds: 200), + Duration maxPeriod = const Duration(milliseconds: 200), + }) { + assert(minPeriod <= maxPeriod); + assert(minPeriod > Duration.zero); + Duration duration() => this.duration ?? Duration.zero; + Duration step() { + var s = duration() ~/ steps; + if (s < minPeriod) s = minPeriod; + if (s > maxPeriod) s = maxPeriod; + return s; + } + + StreamController controller = StreamController.broadcast(); + Timer currentTimer; + StreamSubscription durationSubscription; + void yieldPosition(Timer timer) { + if (controller.isClosed) { + timer.cancel(); + durationSubscription.cancel(); + return; + } + if (_durationSubject.isClosed) { + timer.cancel(); + durationSubscription.cancel(); + controller.close(); + return; + } + controller.add(position); + } + + currentTimer = Timer.periodic(step(), yieldPosition); + durationSubscription = durationStream.listen((duration) { + currentTimer.cancel(); + currentTimer = Timer.periodic(step(), yieldPosition); + }); + return Rx.combineLatest2( + playbackEventStream, controller.stream, (event, period) => position) + .distinct(); + } + /// Convenience method to load audio from a URL with optional headers, /// equivalent to: /// /// ``` - /// load(ProgressiveAudioSource(Uri.parse(url), headers: headers)); + /// load(AudioSource.uri(Uri.parse(url), headers: headers)); /// ``` /// /// @@ -279,39 +340,32 @@ class AudioPlayer { /// Convenience method to load audio from a file, equivalent to: /// /// ``` - /// load(ProgressiveAudioSource(Uri.file(filePath))); + /// load(AudioSource.uri(Uri.file(filePath))); /// ``` Future setFilePath(String filePath) => - load(ProgressiveAudioSource(Uri.file(filePath))); + load(AudioSource.uri(Uri.file(filePath))); /// Convenience method to load audio from an asset, equivalent to: /// /// ``` - /// load(ProgressiveAudioSource(Uri.parse('asset://$filePath'))); + /// load(AudioSource.uri(Uri.parse('asset://$filePath'))); /// ``` Future setAsset(String assetPath) => - load(ProgressiveAudioSource(Uri.parse('asset://$assetPath'))); + load(AudioSource.uri(Uri.parse('asset://$assetPath'))); - /// Loads audio from an [AudioSource] and completes with the duration of that - /// audio, or an exception if this call was interrupted by another - /// call to [load], or if for any reason the audio source was unable to be - /// loaded. + /// Loads audio from an [AudioSource] and completes when the audio is ready + /// to play with the duration of that audio, or an exception if this call was + /// interrupted by another call to [load], or if for any reason the audio + /// source was unable to be loaded. /// /// If the duration is unknown, null will be returned. - /// - /// On Android, DASH and HLS streams are detected only when the URL's path - /// has an "mpd" or "m3u8" extension. If the URL does not have such an - /// extension and you have no control over the server, and you also know the - /// type of the stream in advance, you may as a workaround supply the - /// extension as a URL fragment. e.g. - /// https://somewhere.com/somestream?x=etc#.m3u8 Future load(AudioSource source) async { try { _audioSource = source; final duration = await _load(source); - // Wait for connecting state to pass. - await playbackStateStream - .firstWhere((state) => state != AudioPlaybackState.connecting); + // Wait for loading state to pass. + await processingStateStream + .firstWhere((state) => state != ProcessingState.loading); return duration; } catch (e) { _audioSource = null; @@ -357,112 +411,67 @@ class AudioPlayer { start: start, end: end, )); - // Wait for connecting state to pass. - await playbackStateStream - .firstWhere((state) => state != AudioPlaybackState.connecting); + // Wait for loading state to pass. + await processingStateStream + .firstWhere((state) => state != ProcessingState.loading); return duration; } - /// Plays the currently loaded media from the current position. The [Future] - /// returned by this method completes when playback completes or is paused or - /// stopped. This method can be called from any state except for: + /// Tells the player to play audio as soon as an audio source is loaded and + /// ready to play. The [Future] returned by this method completes when the + /// playback completes or is paused or stopped. If the player is already + /// playing, this method completes immediately. /// - /// * [AudioPlaybackState.connecting] - /// * [AudioPlaybackState.none] + /// This method causes [playing] to become true, and it will remain true + /// until [pause] or [stop] is called. This means that if playback completes, + /// and then you [seek] to an earlier position in the audio, playback will + /// continue playing from that position. If you instead wish to [pause] or + /// [stop] playback on completion, you can call either method as soon as + /// [processingState] becomes [ProcessingState.completed] by listening to + /// [processingStateStream]. Future play() async { - switch (playbackState) { - case AudioPlaybackState.playing: - case AudioPlaybackState.stopped: - case AudioPlaybackState.completed: - case AudioPlaybackState.paused: - // Update local state immediately so that queries aren't surprised. - _audioPlaybackEvent = _audioPlaybackEvent.copyWith( - state: AudioPlaybackState.playing, - ); - StreamSubscription subscription; - Completer completer = Completer(); - bool startedPlaying = false; - subscription = playbackStateStream.listen((state) { - // TODO: It will be more reliable to let the platform - // side wait for completion since events on the flutter - // side can lag behind the platform side. - if (startedPlaying && - (state == AudioPlaybackState.paused || - state == AudioPlaybackState.stopped || - state == AudioPlaybackState.completed)) { - subscription.cancel(); - completer.complete(); - } else if (state == AudioPlaybackState.playing) { - startedPlaying = true; - } - }); - await _invokeMethod('play'); - await completer.future; - break; - default: - throw Exception( - "Cannot call play from connecting/none states ($playbackState)"); - } + if (playing) return; + _playingSubject.add(true); + // TODO: Make platform side wait for playback to stop on iOS. + await _invokeMethod('play'); } - /// Pauses the currently playing media. It is legal to invoke this method - /// only from the [AudioPlaybackState.playing] state. + /// Pauses the currently playing media. This method does nothing if + /// ![playing]. Future pause() async { - switch (playbackState) { - case AudioPlaybackState.paused: - break; - case AudioPlaybackState.playing: - // Update local state immediately so that queries aren't surprised. - _audioPlaybackEvent = _audioPlaybackEvent.copyWith( - state: AudioPlaybackState.paused, - ); - // TODO: For pause, perhaps modify platform side to ensure new state - // is broadcast before this method returns. - await _invokeMethod('pause'); - break; - default: - throw Exception( - "Can call pause only from playing and buffering states ($playbackState)"); - } + if (!playing) return; + // Update local state immediately so that queries aren't surprised. + _playbackEvent = _playbackEvent.copyWith( + updatePosition: position, + updateTime: DateTime.now(), + ); + _playbackEventSubject.add(_playbackEvent); + _playingSubject.add(false); + // TODO: perhaps modify platform side to ensure new state is broadcast + // before this method returns. + 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 only from - /// the following states: - /// - /// * [AudioPlaybackState.playing] - /// * [AudioPlaybackState.paused] - /// * [AudioPlaybackState.completed] + /// Convenience method to pause and seek to zero. Future stop() async { - switch (playbackState) { - case AudioPlaybackState.stopped: - break; - case AudioPlaybackState.connecting: - case AudioPlaybackState.completed: - case AudioPlaybackState.playing: - case AudioPlaybackState.paused: - // Update local state immediately so that queries aren't surprised. - // NOTE: Android implementation already handles this. - // TODO: Do the same for iOS so the line below becomes unnecessary. - _audioPlaybackEvent = _audioPlaybackEvent.copyWith( - state: AudioPlaybackState.paused, - ); - await _invokeMethod('stop'); - break; - default: - throw Exception("Cannot call stop from none state"); - } + await pause(); + await seek(Duration.zero); } /// Sets the volume of this player, where 1.0 is normal volume. Future setVolume(final double volume) async { - _volume = volume; + _volumeSubject.add(volume); await _invokeMethod('setVolume', [volume]); } /// Sets the playback speed of this player, where 1.0 is normal speed. Future setSpeed(final double speed) async { - _speed = speed; + _playbackEvent = _playbackEvent.copyWith( + updatePosition: position, + updateTime: DateTime.now(), + ); + _playbackEventSubject.add(_playbackEvent); + _speedSubject.add(speed); await _invokeMethod('setSpeed', [speed]); } @@ -490,25 +499,25 @@ class AudioPlayer { /// Seeks to a particular [position]. If a composition of multiple /// [AudioSource]s has been loaded, you may also specify [index] to seek to a - /// particular item within that sequence. It is legal to invoke this method - /// from any state except for [AudioPlaybackState.none] and - /// [AudioPlaybackState.connecting]. + /// particular item within that sequence. This method has no effect unless + /// an audio source has been loaded. Future seek(final Duration position, {int index}) async { - // Update local state immediately so that queries aren't surprised. - _audioPlaybackEvent = _audioPlaybackEvent.copyWith( - updatePosition: position, - updateTime: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch), - ); - _playbackEventSubject.add(_audioPlaybackEvent); - await _invokeMethod('seek', [position?.inMilliseconds, index]); + switch (processingState) { + case ProcessingState.none: + case ProcessingState.loading: + return; + default: + _playbackEvent = _playbackEvent.copyWith( + updatePosition: position, + updateTime: DateTime.now(), + ); + _playbackEventSubject.add(_playbackEvent); + await _invokeMethod('seek', [position?.inMilliseconds, index]); + } } /// Release all resources associated with this player. You must invoke this - /// after you are done with the player. This method can be invoked from any - /// state except for: - /// - /// * [AudioPlaybackState.none] - /// * [AudioPlaybackState.connecting] + /// after you are done with the player. Future dispose() async { await _invokeMethod('dispose'); _audioSource = null; @@ -520,6 +529,14 @@ class AudioPlayer { await _playbackEventSubject.close(); await _loopModeSubject.close(); await _shuffleModeEnabledSubject.close(); + await _playingSubject.close(); + await _volumeSubject.close(); + await _speedSubject.close(); + await _playerStateSubject.drain(); + await _playerStateSubject.close(); + if (_positionSubject != null) { + await _positionSubject.close(); + } } Future _invokeMethod(String method, [dynamic args]) async => @@ -527,16 +544,13 @@ class AudioPlayer { } /// Encapsulates the playback state and current position of the player. -class AudioPlaybackEvent { - /// The current playback state. - final AudioPlaybackState state; - - /// Whether the player is buffering. - final bool buffering; +class PlaybackEvent { + /// The current processing state. + final ProcessingState processingState; /// When the last time a position discontinuity happened, as measured in time /// since the epoch. - final Duration updateTime; + final DateTime updateTime; /// The position at [updateTime]. final Duration updatePosition; @@ -544,33 +558,28 @@ class AudioPlaybackEvent { /// The buffer position. final Duration bufferedPosition; - /// The playback speed. - final double speed; - /// The media duration, or null if unknown. final Duration duration; + /// The latest ICY metadata received through the audio stream. final IcyMetadata icyMetadata; /// The index of the currently playing item. final int currentIndex; - AudioPlaybackEvent({ - @required this.state, - @required this.buffering, + PlaybackEvent({ + @required this.processingState, @required this.updateTime, @required this.updatePosition, @required this.bufferedPosition, - @required this.speed, @required this.duration, @required this.icyMetadata, @required this.currentIndex, }); - AudioPlaybackEvent copyWith({ - AudioPlaybackState state, - bool buffering, - Duration updateTime, + PlaybackEvent copyWith({ + ProcessingState processingState, + DateTime updateTime, Duration updatePosition, Duration bufferedPosition, double speed, @@ -578,71 +587,64 @@ class AudioPlaybackEvent { IcyMetadata icyMetadata, UriAudioSource currentIndex, }) => - AudioPlaybackEvent( - state: state ?? this.state, - buffering: buffering ?? this.buffering, + PlaybackEvent( + processingState: processingState ?? this.processingState, updateTime: updateTime ?? this.updateTime, updatePosition: updatePosition ?? this.updatePosition, bufferedPosition: bufferedPosition ?? this.bufferedPosition, - speed: speed ?? this.speed, duration: duration ?? this.duration, icyMetadata: icyMetadata ?? this.icyMetadata, currentIndex: currentIndex ?? this.currentIndex, ); - /// The current position of the player. - Duration get position { - if (state == AudioPlaybackState.playing && !buffering) { - final result = updatePosition + - (Duration(milliseconds: DateTime.now().millisecondsSinceEpoch) - - updateTime) * - speed; - return duration == null || result <= duration ? result : duration; - } else { - return updatePosition; - } - } - @override String toString() => - "{state=$state, updateTime=$updateTime, updatePosition=$updatePosition, speed=$speed}"; + "{processingState=$processingState, updateTime=$updateTime, updatePosition=$updatePosition}"; } -/// Enumerates the different playback states of a player. -/// -/// If you also need access to the buffering state, use -/// [FullAudioPlaybackState]. -enum AudioPlaybackState { +/// Enumerates the different processing states of a player. +enum ProcessingState { + /// The player has not loaded an [AudioSource]. none, - stopped, - paused, - playing, - connecting, + + /// The player is loading an [AudioSource]. + loading, + + /// The player is buffering audio and unable to play. + buffering, + + /// The player is has enough audio buffered and is able to play. + ready, + + /// The player has reached the end of the audio. completed, } -/// Encapsulates the playback state and the buffering state. -/// -/// These two states vary orthogonally, and so if [buffering] is true, you can -/// check [state] to determine whether this buffering is occurring during the -/// playing state or the paused state. -class FullAudioPlaybackState { - final AudioPlaybackState state; - final bool buffering; - final IcyMetadata icyMetadata; +/// Encapsulates the playing and processing states. These two states vary +/// orthogonally, and so if [processingState] is [ProcessingState.buffering], +/// you can check [playing] to determine whether the buffering occurred while +/// the player was playing or while the player was paused. +class PlayerState { + /// Whether the player will play when [processingState] is + /// [ProcessingState.ready]. + final bool playing; - FullAudioPlaybackState(this.state, this.buffering, this.icyMetadata); + /// The current processing state of the player. + final ProcessingState processingState; + + PlayerState(this.playing, this.processingState); @override - int get hashCode => - icyMetadata.hashCode * (state.index + 1) * (buffering ? 2 : 1); + String toString() => 'playing=$playing,processingState=$processingState'; + + @override + int get hashCode => toString().hashCode; @override bool operator ==(dynamic other) => - other is FullAudioPlaybackState && - other?.state == state && - other?.buffering == buffering && - other?.icyMetadata == icyMetadata; + other is PlayerState && + other?.playing == playing && + other?.processingState == processingState; } class IcyInfo { @@ -861,6 +863,12 @@ abstract class AudioSource { /// attempting to guess the type of stream. On iOS, this uses Apple's SDK to /// automatically detect the stream type. On Android, the type of stream will /// be guessed from the extension. + /// + /// If you are loading DASH or HLS streams that do not have standard "mpd" or + /// "m3u8" extensions in their URIs, this method will fail to detect the + /// stream type on Android. If you know in advance what type of audio stream + /// it is, you should instantiate [DashAudioSource] or [HlsAudioSource] + /// directly. static AudioSource uri(Uri uri, {Map headers, Object tag}) { bool hasExtension(Uri uri, String extension) => uri.path.toLowerCase().endsWith('.$extension') || diff --git a/lib/just_audio_web.dart b/lib/just_audio_web.dart index 55ae75f..1df5735 100644 --- a/lib/just_audio_web.dart +++ b/lib/just_audio_web.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:html'; import 'dart:math'; -import 'package:async/async.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; @@ -44,8 +43,8 @@ abstract class JustAudioPlayer { final MethodChannel methodChannel; final PluginEventChannel eventChannel; final StreamController eventController = StreamController(); - AudioPlaybackState _state = AudioPlaybackState.none; - bool _buffering = false; + ProcessingState _processingState = ProcessingState.none; + bool _playing = false; int _index; JustAudioPlayer({@required this.id, @required this.registrar}) @@ -67,8 +66,6 @@ abstract class JustAudioPlayer { return await play(); case 'pause': return await pause(); - case 'stop': - return await stop(); case 'setVolume': return await setVolume(args[0]); case 'setSpeed': @@ -114,8 +111,6 @@ abstract class JustAudioPlayer { Future pause(); - Future stop(); - Future setVolume(double volume); Future setSpeed(double speed); @@ -133,6 +128,8 @@ abstract class JustAudioPlayer { Duration getCurrentPosition(); + Duration getBufferedPosition(); + Duration getDuration(); concatenatingAdd(String playerId, Map source); @@ -154,12 +151,10 @@ abstract class JustAudioPlayer { broadcastPlaybackEvent() { var updateTime = DateTime.now().millisecondsSinceEpoch; eventController.add({ - 'state': _state.index, - 'buffering': _buffering, + 'processingState': _processingState.index, 'updatePosition': getCurrentPosition()?.inMilliseconds, 'updateTime': updateTime, - // TODO: buffered position - 'bufferedPosition': getCurrentPosition()?.inMilliseconds, + 'bufferedPosition': getBufferedPosition()?.inMilliseconds, // TODO: Icy Metadata 'icyMetadata': null, 'duration': getDuration()?.inMilliseconds, @@ -167,8 +162,8 @@ abstract class JustAudioPlayer { }); } - transition(AudioPlaybackState state) { - _state = state; + transition(ProcessingState processingState) { + _processingState = processingState; broadcastPlaybackEvent(); } } @@ -179,26 +174,36 @@ class Html5AudioPlayer extends JustAudioPlayer { AudioSourcePlayer _audioSourcePlayer; LoopMode _loopMode = LoopMode.off; bool _shuffleModeEnabled = false; - bool _playing = false; final Map _audioSourcePlayers = {}; Html5AudioPlayer({@required String id, @required Registrar registrar}) : super(id: id, registrar: registrar) { _audioElement.addEventListener('durationchange', (event) { _durationCompleter?.complete(); + broadcastPlaybackEvent(); }); _audioElement.addEventListener('error', (event) { _durationCompleter?.completeError(_audioElement.error); }); _audioElement.addEventListener('ended', (event) async { - onEnded(); + _currentAudioSourcePlayer.complete(); }); - _audioElement.addEventListener('seek', (event) { - _buffering = true; - broadcastPlaybackEvent(); + _audioElement.addEventListener('timeupdate', (event) { + _currentAudioSourcePlayer.timeUpdated(_audioElement.currentTime); }); - _audioElement.addEventListener('seeked', (event) { - _buffering = false; + _audioElement.addEventListener('loadstart', (event) { + transition(ProcessingState.buffering); + }); + _audioElement.addEventListener('waiting', (event) { + transition(ProcessingState.buffering); + }); + _audioElement.addEventListener('stalled', (event) { + transition(ProcessingState.buffering); + }); + _audioElement.addEventListener('canplaythrough', (event) { + transition(ProcessingState.ready); + }); + _audioElement.addEventListener('progress', (event) { broadcastPlaybackEvent(); }); } @@ -245,18 +250,17 @@ class Html5AudioPlayer extends JustAudioPlayer { // Loop back to the beginning if (order.length == 1) { await seek(0, null); - await play(); + play(); } else { _index = order[0]; await _currentAudioSourcePlayer.load(); // Should always be true... if (_playing) { - await play(); + play(); } } } else { - _playing = false; - transition(AudioPlaybackState.completed); + transition(ProcessingState.completed); } } } @@ -280,7 +284,7 @@ class Html5AudioPlayer extends JustAudioPlayer { } Future loadUri(final Uri uri) async { - transition(AudioPlaybackState.connecting); + transition(ProcessingState.loading); final src = uri.toString(); if (src != _audioElement.src) { _durationCompleter = Completer(); @@ -296,7 +300,7 @@ class Html5AudioPlayer extends JustAudioPlayer { _durationCompleter = null; } } - transition(AudioPlaybackState.stopped); + transition(ProcessingState.ready); final seconds = _audioElement.duration; return seconds.isFinite ? Duration(milliseconds: (seconds * 1000).toInt()) @@ -306,22 +310,13 @@ class Html5AudioPlayer extends JustAudioPlayer { @override Future play() async { _playing = true; - _currentAudioSourcePlayer.play(); - transition(AudioPlaybackState.playing); + await _currentAudioSourcePlayer.play(); } @override Future pause() async { _playing = false; _currentAudioSourcePlayer.pause(); - transition(AudioPlaybackState.paused); - } - - @override - Future stop() async { - _playing = false; - _currentAudioSourcePlayer.stop(); - transition(AudioPlaybackState.stopped); } @override @@ -356,7 +351,7 @@ class Html5AudioPlayer extends JustAudioPlayer { await _currentAudioSourcePlayer.load(); await _currentAudioSourcePlayer.seek(position); if (_playing) { - await play(); + _currentAudioSourcePlayer.play(); } } else { await _currentAudioSourcePlayer.seek(position); @@ -447,13 +442,16 @@ class Html5AudioPlayer extends JustAudioPlayer { } concatenatingClear(String playerId) { - _currentAudioSourcePlayer.stop(); + _currentAudioSourcePlayer.pause(); _concatenating(playerId).clear(); } @override Duration getCurrentPosition() => _currentAudioSourcePlayer?.position; + @override + Duration getBufferedPosition() => _currentAudioSourcePlayer?.bufferedPosition; + @override Duration getDuration() => _currentAudioSourcePlayer?.duration; @@ -462,7 +460,7 @@ class Html5AudioPlayer extends JustAudioPlayer { _currentAudioSourcePlayer?.pause(); _audioElement.removeAttribute('src'); _audioElement.load(); - transition(AudioPlaybackState.none); + transition(ProcessingState.none); super.dispose(); } @@ -540,14 +538,18 @@ abstract class IndexedAudioSourcePlayer extends AudioSourcePlayer { Future pause(); - Future stop(); - Future seek(int position); + Future complete(); + + Future timeUpdated(double seconds) async {} + Duration get duration; Duration get position; + Duration get bufferedPosition; + AudioElement get _audioElement => html5AudioPlayer._audioElement; @override @@ -562,6 +564,7 @@ abstract class UriAudioSourcePlayer extends IndexedAudioSourcePlayer { final Map headers; double _resumePos; Duration _duration; + Completer _completer; UriAudioSourcePlayer( Html5AudioPlayer html5AudioPlayer, String id, this.uri, this.headers) @@ -583,12 +586,16 @@ abstract class UriAudioSourcePlayer extends IndexedAudioSourcePlayer { Future play() async { _audioElement.currentTime = _resumePos; _audioElement.play(); + _completer = Completer(); + await _completer.future; + _completer = null; } @override Future pause() async { _resumePos = _audioElement.currentTime; _audioElement.pause(); + _interruptPlay(); } @override @@ -597,10 +604,15 @@ abstract class UriAudioSourcePlayer extends IndexedAudioSourcePlayer { } @override - Future stop() async { - _resumePos = 0.0; - _audioElement.pause(); - _audioElement.currentTime = 0.0; + Future complete() async { + _interruptPlay(); + html5AudioPlayer.onEnded(); + } + + _interruptPlay() { + if (_completer?.isCompleted == false) { + _completer.complete(); + } } @override @@ -617,6 +629,19 @@ abstract class UriAudioSourcePlayer extends IndexedAudioSourcePlayer { double seconds = _audioElement.currentTime; return Duration(milliseconds: (seconds * 1000).toInt()); } + + @override + Duration get bufferedPosition { + if (_audioElement.buffered.length > 0) { + return Duration( + milliseconds: + (_audioElement.buffered.end(_audioElement.buffered.length - 1) * + 1000) + .toInt()); + } else { + return Duration.zero; + } + } } class ProgressiveAudioSourcePlayer extends UriAudioSourcePlayer { @@ -775,7 +800,7 @@ class ClippingAudioSourcePlayer extends IndexedAudioSourcePlayer { final UriAudioSourcePlayer audioSourcePlayer; final Duration start; final Duration end; - CancelableOperation _playOperation; + Completer _completer; double _resumePos; Duration _duration; @@ -791,55 +816,61 @@ class ClippingAudioSourcePlayer extends IndexedAudioSourcePlayer { @override Future load() async { - _resumePos = start.inMilliseconds / 1000.0; + _resumePos = (start ?? Duration.zero).inMilliseconds / 1000.0; Duration fullDuration = await html5AudioPlayer.loadUri(audioSourcePlayer.uri); _audioElement.currentTime = _resumePos; _duration = Duration( - milliseconds: min(end.inMilliseconds, fullDuration.inMilliseconds) - - start.inMilliseconds); + milliseconds: min((end ?? fullDuration).inMilliseconds, + fullDuration.inMilliseconds) - + (start ?? Duration.zero).inMilliseconds); return _duration; } + double get remaining => end.inMilliseconds / 1000 - _audioElement.currentTime; + @override Future play() async { - _interruptPlay(); - //_playing = true; - final duration = - end == null ? null : end.inMilliseconds / 1000 - _resumePos; - + _interruptPlay(ClipInterruptReason.simultaneous); _audioElement.currentTime = _resumePos; _audioElement.play(); - if (duration != null) { - _playOperation = CancelableOperation.fromFuture(Future.delayed(Duration( - milliseconds: duration * 1000 ~/ _audioElement.playbackRate))) - .then((_) { - _playOperation = null; - pause(); - html5AudioPlayer.onEnded(); - }); + _completer = Completer(); + ClipInterruptReason reason; + while ((reason = await _completer.future) == ClipInterruptReason.seek) { + _completer = Completer(); } + if (reason == ClipInterruptReason.end) { + html5AudioPlayer.onEnded(); + } + _completer = null; } @override Future pause() async { - _interruptPlay(); + _interruptPlay(ClipInterruptReason.pause); _resumePos = _audioElement.currentTime; _audioElement.pause(); } @override Future seek(int position) async { - _interruptPlay(); + _interruptPlay(ClipInterruptReason.seek); _audioElement.currentTime = _resumePos = start.inMilliseconds / 1000.0 + position / 1000.0; } @override - Future stop() async { - _resumePos = 0.0; - _audioElement.pause(); - _audioElement.currentTime = start.inMilliseconds / 1000.0; + Future complete() async { + _interruptPlay(ClipInterruptReason.end); + } + + @override + Future timeUpdated(double seconds) async { + if (end != null) { + if (seconds >= end.inMilliseconds / 1000) { + _interruptPlay(ClipInterruptReason.end); + } + } } @override @@ -860,12 +891,36 @@ class ClippingAudioSourcePlayer extends IndexedAudioSourcePlayer { return position; } - _interruptPlay() { - _playOperation?.cancel(); - _playOperation = null; + @override + Duration get bufferedPosition { + if (_audioElement.buffered.length > 0) { + var seconds = + _audioElement.buffered.end(_audioElement.buffered.length - 1); + var position = Duration(milliseconds: (seconds * 1000).toInt()); + if (start != null) { + position -= start; + } + if (position < Duration.zero) { + position = Duration.zero; + } + if (duration != null && position > duration) { + position = duration; + } + return position; + } else { + return Duration.zero; + } + } + + _interruptPlay(ClipInterruptReason reason) { + if (_completer?.isCompleted == false) { + _completer.complete(reason); + } } } +enum ClipInterruptReason { end, pause, seek, simultaneous } + class LoopingAudioSourcePlayer extends AudioSourcePlayer { final AudioSourcePlayer audioSourcePlayer; final int count; diff --git a/pubspec.lock b/pubspec.lock index 152a131..6c8bd0b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,27 +1,13 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - archive: - dependency: transitive - description: - name: archive - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.13" - args: - dependency: transitive - description: - name: args - url: "https://pub.dartlang.org" - source: hosted - version: "1.6.0" async: dependency: "direct main" description: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.4.1" + version: "2.4.2" boolean_selector: dependency: transitive description: @@ -29,6 +15,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" charcode: dependency: transitive description: @@ -36,13 +29,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.3" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" collection: dependency: transitive description: name: collection url: "https://pub.dartlang.org" source: hosted - version: "1.14.12" + version: "1.14.13" convert: dependency: transitive description: @@ -57,6 +57,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.4" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" file: dependency: transitive description: @@ -79,13 +86,6 @@ packages: description: flutter source: sdk version: "0.0.0" - image: - dependency: transitive - description: - name: image - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.12" intl: dependency: transitive description: @@ -99,7 +99,7 @@ packages: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.6" + version: "0.12.8" meta: dependency: transitive description: @@ -113,7 +113,7 @@ packages: name: path url: "https://pub.dartlang.org" source: hosted - version: "1.6.4" + version: "1.7.0" path_provider: dependency: "direct main" description: @@ -142,13 +142,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.2" - petitparser: - dependency: transitive - description: - name: petitparser - url: "https://pub.dartlang.org" - source: hosted - version: "2.4.0" platform: dependency: transitive description: @@ -170,13 +163,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.13" - quiver: - dependency: transitive - description: - name: quiver - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.3" rxdart: dependency: "direct main" description: @@ -202,7 +188,7 @@ packages: name: stack_trace url: "https://pub.dartlang.org" source: hosted - version: "1.9.3" + version: "1.9.5" stream_channel: dependency: transitive description: @@ -230,14 +216,14 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.2.15" + version: "0.2.17" typed_data: dependency: transitive description: name: typed_data url: "https://pub.dartlang.org" source: hosted - version: "1.1.6" + version: "1.2.0" uuid: dependency: "direct main" description: @@ -259,13 +245,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.0" - xml: - dependency: transitive - description: - name: xml - url: "https://pub.dartlang.org" - source: hosted - version: "3.6.1" sdks: - dart: ">=2.6.0 <3.0.0" + dart: ">=2.9.0-14.0.dev <3.0.0" flutter: ">=1.12.13+hotfix.5 <2.0.0"