Separate buffering from playbackState. Improve Android buffering detection.

This commit is contained in:
Ryan Heise 2020-02-05 12:26:21 +11:00
parent 280f1a8208
commit eee50d712e
5 changed files with 84 additions and 104 deletions

View File

@ -35,23 +35,9 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener {
private final MethodChannel methodChannel; private final MethodChannel methodChannel;
private final EventChannel eventChannel; private final EventChannel eventChannel;
private EventSink eventSink; private EventSink eventSink;
private final Handler handler = new Handler();
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 String id; private final String id;
private volatile PlaybackState state; private volatile PlaybackState state;
private PlaybackState stateBeforeSeek;
private long updateTime; private long updateTime;
private long updatePosition; private long updatePosition;
@ -64,6 +50,7 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener {
private Result prepareResult; private Result prepareResult;
private Result seekResult; private Result seekResult;
private boolean seekProcessed; private boolean seekProcessed;
private boolean buffering;
private MediaSource mediaSource; private MediaSource mediaSource;
private SimpleExoPlayer player; private SimpleExoPlayer player;
@ -106,15 +93,19 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener {
completeSeek(); completeSeek();
} }
break; break;
case Player.STATE_BUFFERING:
// TODO: use this instead of checkForDiscontinuity.
break;
case Player.STATE_ENDED: case Player.STATE_ENDED:
if (state != PlaybackState.completed) { if (state != PlaybackState.completed) {
transition(PlaybackState.completed); transition(PlaybackState.completed);
} }
break; 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();
}
} }
@Override @Override
@ -130,29 +121,10 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener {
private void completeSeek() { private void completeSeek() {
seekProcessed = false; seekProcessed = false;
seekPos = null; seekPos = null;
transition(stateBeforeSeek);
seekResult.success(null); seekResult.success(null);
stateBeforeSeek = null;
seekResult = null; seekResult = null;
} }
private void checkForDiscontinuity() {
final long now = System.currentTimeMillis();
final long position = getCurrentPosition();
final long timeSinceLastUpdate = now - updateTime;
final long expectedPosition = updatePosition + (long)(timeSinceLastUpdate * speed);
final long drift = position - expectedPosition;
// Update if we've drifted or just started observing
if (updateTime == 0L) {
broadcastPlaybackEvent();
} else if (drift < -100) {
System.out.println("time discontinuity detected: " + drift);
transition(PlaybackState.buffering);
} else if (state == PlaybackState.buffering) {
transition(PlaybackState.playing);
}
}
@Override @Override
public void onMethodCall(final MethodCall call, final Result result) { public void onMethodCall(final MethodCall call, final Result result) {
final List<?> args = (List<?>)call.arguments; final List<?> args = (List<?>)call.arguments;
@ -219,6 +191,7 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener {
private void broadcastPlaybackEvent() { private void broadcastPlaybackEvent() {
final ArrayList<Object> event = new ArrayList<Object>(); final ArrayList<Object> event = new ArrayList<Object>();
event.add(state.ordinal()); event.add(state.ordinal());
event.add(buffering);
event.add(updatePosition = getCurrentPosition()); event.add(updatePosition = getCurrentPosition());
event.add(updateTime = System.currentTimeMillis()); event.add(updateTime = System.currentTimeMillis());
eventSink.success(event); eventSink.success(event);
@ -237,9 +210,6 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener {
private void transition(final PlaybackState newState) { private void transition(final PlaybackState newState) {
final PlaybackState oldState = state; final PlaybackState oldState = state;
state = newState; state = newState;
if (oldState != PlaybackState.playing && newState == PlaybackState.playing) {
startObservingPosition();
}
broadcastPlaybackEvent(); broadcastPlaybackEvent();
} }
@ -282,14 +252,9 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener {
break; break;
case stopped: case stopped:
case completed: case completed:
case buffering:
case paused: case paused:
player.setPlayWhenReady(true);
if (seekResult != null) {
stateBeforeSeek = PlaybackState.playing;
} else {
transition(PlaybackState.playing); transition(PlaybackState.playing);
} player.setPlayWhenReady(true);
break; break;
default: default:
throw new IllegalStateException("Cannot call play from connecting/none states (" + state + ")"); throw new IllegalStateException("Cannot call play from connecting/none states (" + state + ")");
@ -301,12 +266,8 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener {
case paused: case paused:
break; break;
case playing: case playing:
case buffering:
player.setPlayWhenReady(false); player.setPlayWhenReady(false);
transition(PlaybackState.paused); transition(PlaybackState.paused);
if (seekResult != null) {
stateBeforeSeek = PlaybackState.paused;
}
break; break;
default: default:
throw new IllegalStateException("Can call pause only from playing and buffering states (" + state + ")"); throw new IllegalStateException("Can call pause only from playing and buffering states (" + state + ")");
@ -320,18 +281,17 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener {
break; break;
case connecting: case connecting:
abortExistingConnection(); abortExistingConnection();
buffering = false;
transition(PlaybackState.stopped); transition(PlaybackState.stopped);
result.success(null); result.success(null);
break; break;
case buffering:
abortSeek();
// no break
case completed: case completed:
case playing: case playing:
case paused: case paused:
abortSeek();
player.setPlayWhenReady(false); player.setPlayWhenReady(false);
player.seekTo(0L);
transition(PlaybackState.stopped); transition(PlaybackState.stopped);
player.seekTo(0L);
result.success(null); result.success(null);
break; break;
default: default:
@ -358,11 +318,6 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener {
seekPos = position; seekPos = position;
seekResult = result; seekResult = result;
seekProcessed = false; seekProcessed = false;
if (stateBeforeSeek == null) {
stateBeforeSeek = state;
}
handler.removeCallbacks(positionObserver);
transition(PlaybackState.buffering);
player.seekTo(position); player.seekTo(position);
} }
@ -376,7 +331,6 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener {
seekResult.success(null); seekResult.success(null);
seekResult = null; seekResult = null;
seekPos = null; seekPos = null;
stateBeforeSeek = null;
seekProcessed = false; seekProcessed = false;
} }
} }
@ -388,17 +342,11 @@ public class AudioPlayer implements MethodCallHandler, Player.EventListener {
} }
} }
private void startObservingPosition() {
handler.removeCallbacks(positionObserver);
handler.post(positionObserver);
}
enum PlaybackState { enum PlaybackState {
none, none,
stopped, stopped,
paused, paused,
playing, playing,
buffering,
connecting, connecting,
completed completed
} }

View File

@ -43,27 +43,29 @@ class _MyAppState extends State<MyApp> {
children: [ children: [
Text("Science Friday"), Text("Science Friday"),
Text("Science Friday and WNYC Studios"), Text("Science Friday and WNYC Studios"),
StreamBuilder<AudioPlaybackState>( StreamBuilder<FullAudioPlaybackState>(
stream: _player.playbackStateStream, stream: _player.fullPlaybackStateStream,
builder: (context, snapshot) { builder: (context, snapshot) {
final state = snapshot.data; final fullState = snapshot.data;
final state = fullState?.state;
final buffering = fullState?.buffering;
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (state == AudioPlaybackState.playing) if (state == AudioPlaybackState.connecting ||
IconButton( buffering == true)
icon: Icon(Icons.pause),
iconSize: 64.0,
onPressed: _player.pause,
)
else if (state == AudioPlaybackState.buffering ||
state == AudioPlaybackState.connecting)
Container( Container(
margin: EdgeInsets.all(8.0), margin: EdgeInsets.all(8.0),
width: 64.0, width: 64.0,
height: 64.0, height: 64.0,
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
) )
else if (state == AudioPlaybackState.playing)
IconButton(
icon: Icon(Icons.pause),
iconSize: 64.0,
onPressed: _player.pause,
)
else else
IconButton( IconButton(
icon: Icon(Icons.play_arrow), icon: Icon(Icons.play_arrow),

View File

@ -11,7 +11,6 @@ enum PlaybackState {
stopped, stopped,
paused, paused,
playing, playing,
buffering,
connecting, connecting,
completed completed
}; };

View File

@ -10,11 +10,11 @@
NSString* _playerId; NSString* _playerId;
AVPlayer* _player; AVPlayer* _player;
enum PlaybackState _state; enum PlaybackState _state;
enum PlaybackState _stateBeforeSeek;
long long _updateTime; long long _updateTime;
int _updatePosition; int _updatePosition;
int _seekPos; int _seekPos;
FlutterResult _connectionResult; FlutterResult _connectionResult;
BOOL _buffering;
id _endObserver; id _endObserver;
id _timeObserver; id _timeObserver;
} }
@ -32,9 +32,9 @@
binaryMessenger:[registrar messenger]]; binaryMessenger:[registrar messenger]];
[_eventChannel setStreamHandler:self]; [_eventChannel setStreamHandler:self];
_state = none; _state = none;
_stateBeforeSeek = none;
_player = nil; _player = nil;
_seekPos = -1; _seekPos = -1;
_buffering = NO;
_endObserver = 0; _endObserver = 0;
_timeObserver = 0; _timeObserver = 0;
__weak __typeof__(self) weakSelf = self; __weak __typeof__(self) weakSelf = self;
@ -94,7 +94,7 @@
- (void)checkForDiscontinuity { - (void)checkForDiscontinuity {
if (!_eventSink) return; if (!_eventSink) return;
if (_state != playing && _state != buffering) return; if ((_state != playing) && !_buffering) return;
long long now = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); long long now = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0);
int position = [self getCurrentPosition]; int position = [self getCurrentPosition];
long long timeSinceLastUpdate = now - _updateTime; long long timeSinceLastUpdate = now - _updateTime;
@ -105,9 +105,11 @@
[self broadcastPlaybackEvent]; [self broadcastPlaybackEvent];
} else if (drift < -100) { } else if (drift < -100) {
NSLog(@"time discontinuity detected: %lld", drift); NSLog(@"time discontinuity detected: %lld", drift);
[self setPlaybackState:buffering]; _buffering = YES;
} else if (_state == buffering) { [self broadcastPlaybackEvent];
[self setPlaybackState:playing]; } else if (_buffering) {
_buffering = NO;
[self broadcastPlaybackEvent];
} }
} }
@ -118,6 +120,8 @@
_eventSink(@[ _eventSink(@[
// state // state
@(_state), @(_state),
// buffering
@(_buffering),
// updatePosition // updatePosition
@(_updatePosition), @(_updatePosition),
// updateTime // updateTime
@ -292,10 +296,10 @@
} }
- (void)seek:(int)position result:(FlutterResult)result { - (void)seek:(int)position result:(FlutterResult)result {
_stateBeforeSeek = _state;
_seekPos = position; _seekPos = position;
NSLog(@"seek. enter buffering"); NSLog(@"seek. enter buffering");
[self setPlaybackState:buffering]; _buffering = YES;
[self broadcastPlaybackEvent];
[_player seekToTime:CMTimeMake(position, 1000) [_player seekToTime:CMTimeMake(position, 1000)
completionHandler:^(BOOL finished) { completionHandler:^(BOOL finished) {
NSLog(@"seek completed"); NSLog(@"seek completed");
@ -305,8 +309,8 @@
- (void)onSeekCompletion:(FlutterResult)result { - (void)onSeekCompletion:(FlutterResult)result {
_seekPos = -1; _seekPos = -1;
[self setPlaybackState:_stateBeforeSeek]; _buffering = NO;
_stateBeforeSeek = none; [self broadcastPlaybackEvent];
result(nil); result(nil);
} }

View File

@ -34,10 +34,7 @@ import 'package:rxdart/rxdart.dart';
/// * [AudioPlaybackState.stopped]: eventually after [setUrl], [setFilePath], /// * [AudioPlaybackState.stopped]: eventually after [setUrl], [setFilePath],
/// [setAsset] or [setClip] completes, and immediately after [stop]. /// [setAsset] or [setClip] completes, and immediately after [stop].
/// * [AudioPlaybackState.paused]: after [pause]. /// * [AudioPlaybackState.paused]: after [pause].
/// * [AudioPlaybackState.playing]: after [play] and after sufficiently /// * [AudioPlaybackState.playing]: after [play].
/// 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], /// * [AudioPlaybackState.connecting]: immediately after [setUrl],
/// [setFilePath] and [setAsset] while waiting for the media to load. /// [setFilePath] and [setAsset] while waiting for the media to load.
/// * [AudioPlaybackState.completed]: immediately after playback reaches the /// * [AudioPlaybackState.completed]: immediately after playback reaches the
@ -64,6 +61,7 @@ class AudioPlayer {
// TODO: also broadcast this event on instantiation. // TODO: also broadcast this event on instantiation.
AudioPlaybackEvent _audioPlaybackEvent = AudioPlaybackEvent( AudioPlaybackEvent _audioPlaybackEvent = AudioPlaybackEvent(
state: AudioPlaybackState.none, state: AudioPlaybackState.none,
buffering: false,
updatePosition: Duration.zero, updatePosition: Duration.zero,
updateTime: Duration.zero, updateTime: Duration.zero,
speed: 1.0, speed: 1.0,
@ -77,6 +75,10 @@ class AudioPlayer {
final _playbackStateSubject = BehaviorSubject<AudioPlaybackState>(); final _playbackStateSubject = BehaviorSubject<AudioPlaybackState>();
final _bufferingSubject = BehaviorSubject<bool>();
final _fullPlaybackStateSubject = BehaviorSubject<FullAudioPlaybackState>();
double _volume = 1.0; double _volume = 1.0;
double _speed = 1.0; double _speed = 1.0;
@ -90,14 +92,22 @@ class AudioPlayer {
.receiveBroadcastStream() .receiveBroadcastStream()
.map((data) => _audioPlaybackEvent = AudioPlaybackEvent( .map((data) => _audioPlaybackEvent = AudioPlaybackEvent(
state: AudioPlaybackState.values[data[0]], state: AudioPlaybackState.values[data[0]],
updatePosition: Duration(milliseconds: data[1]), buffering: data[1],
updateTime: Duration(milliseconds: data[2]), updatePosition: Duration(milliseconds: data[2]),
updateTime: Duration(milliseconds: data[3]),
speed: _speed, speed: _speed,
)); ));
_eventChannelStreamSubscription = _eventChannelStreamSubscription =
_eventChannelStream.listen(_playbackEventSubject.add); _eventChannelStream.listen(_playbackEventSubject.add);
_playbackStateSubject _playbackStateSubject
.addStream(playbackEventStream.map((state) => state.state).distinct()); .addStream(playbackEventStream.map((state) => state.state).distinct());
_bufferingSubject.addStream(
playbackEventStream.map((state) => state.buffering).distinct());
_fullPlaybackStateSubject.addStream(
Rx.combineLatest2<AudioPlaybackState, bool, FullAudioPlaybackState>(
playbackStateStream,
bufferingStream,
(state, buffering) => FullAudioPlaybackState(state, buffering)));
} }
/// The duration of any media set via [setUrl], [setFilePath] or [setAsset], /// The duration of any media set via [setUrl], [setFilePath] or [setAsset],
@ -121,6 +131,16 @@ class AudioPlayer {
Stream<AudioPlaybackState> get playbackStateStream => Stream<AudioPlaybackState> get playbackStateStream =>
_playbackStateSubject.stream; _playbackStateSubject.stream;
/// Whether the player is buffering.
bool get buffering => _audioPlaybackEvent.buffering;
/// A stream of buffering state changes.
Stream<bool> get bufferingStream => _bufferingSubject.stream;
/// A stream of [FullAudioPlaybackState]s.
Stream<FullAudioPlaybackState> get fullPlaybackStateStream =>
_fullPlaybackStateSubject.stream;
/// A stream periodically tracking the current position of this player. /// A stream periodically tracking the current position of this player.
Stream<Duration> getPositionStream( Stream<Duration> getPositionStream(
[final Duration period = const Duration(milliseconds: 200)]) => [final Duration period = const Duration(milliseconds: 200)]) =>
@ -209,10 +229,7 @@ class AudioPlayer {
} }
/// Pauses the currently playing media. It is legal to invoke this method /// Pauses the currently playing media. It is legal to invoke this method
/// only from the following states: /// only from the [AudioPlaybackState.playing] state.
///
/// * [AudioPlaybackState.playing]
/// * [AudioPlaybackState.buffering]
Future<void> pause() async { Future<void> pause() async {
await _invokeMethod('pause'); await _invokeMethod('pause');
} }
@ -272,6 +289,9 @@ class AudioPlaybackEvent {
/// The current playback state. /// The current playback state.
final AudioPlaybackState state; final AudioPlaybackState state;
/// Whether the player is buffering.
final bool buffering;
/// When the last time a position discontinuity happened, as measured in time /// When the last time a position discontinuity happened, as measured in time
/// since the epoch. /// since the epoch.
final Duration updateTime; final Duration updateTime;
@ -284,6 +304,7 @@ class AudioPlaybackEvent {
AudioPlaybackEvent({ AudioPlaybackEvent({
@required this.state, @required this.state,
@required this.buffering,
@required this.updateTime, @required this.updateTime,
@required this.updatePosition, @required this.updatePosition,
@required this.speed, @required this.speed,
@ -308,7 +329,13 @@ enum AudioPlaybackState {
stopped, stopped,
paused, paused,
playing, playing,
buffering,
connecting, connecting,
completed, completed,
} }
class FullAudioPlaybackState {
final AudioPlaybackState state;
final bool buffering;
FullAudioPlaybackState(this.state, this.buffering);
}