Integrate the ExoPlayer library
This commit is contained in:
parent
5e0fc0fd76
commit
fc9e65b371
2
LICENSE
2
LICENSE
|
@ -22,7 +22,7 @@ SOFTWARE.
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
|
|
||||||
This software includes the sonic library which is licensed under the Apache
|
This software includes the ExoPlayer library which is licensed under the Apache
|
||||||
License, Version 2.0.
|
License, Version 2.0.
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -17,9 +17,11 @@ This plugin has been tested on Android, and is being made available for testing
|
||||||
final player = AudioPlayer();
|
final player = AudioPlayer();
|
||||||
await player.setUrl('https://foo.com/bar.mp3');
|
await player.setUrl('https://foo.com/bar.mp3');
|
||||||
player.play();
|
player.play();
|
||||||
await player.pause();
|
player.pause();
|
||||||
await player.play(untilPosition: Duration(minutes: 1));
|
player.play();
|
||||||
await player.stop()
|
await player.stop();
|
||||||
|
await player.setClip(start: Duration(seconds: 10), end: Duration(seconds: 20));
|
||||||
|
await player.play();
|
||||||
await player.setUrl('https://foo.com/baz.mp3');
|
await player.setUrl('https://foo.com/baz.mp3');
|
||||||
await player.seek(Duration(minutes: 5));
|
await player.seek(Duration(minutes: 5));
|
||||||
player.play();
|
player.play();
|
||||||
|
|
|
@ -38,3 +38,10 @@ android {
|
||||||
targetCompatibility 1.8
|
targetCompatibility 1.8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'com.google.android.exoplayer:exoplayer-core:2.11.1'
|
||||||
|
implementation 'com.google.android.exoplayer:exoplayer-dash:2.11.1'
|
||||||
|
implementation 'com.google.android.exoplayer:exoplayer-hls:2.11.1'
|
||||||
|
implementation 'com.google.android.exoplayer:exoplayer-smoothstreaming:2.11.1'
|
||||||
|
}
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
package com.ryanheise.just_audio;
|
package com.ryanheise.just_audio;
|
||||||
|
|
||||||
import android.media.AudioFormat;
|
|
||||||
import android.media.AudioManager;
|
|
||||||
import android.media.AudioTrack;
|
|
||||||
import android.media.MediaCodec;
|
|
||||||
import android.media.MediaExtractor;
|
|
||||||
import android.media.MediaFormat;
|
|
||||||
import android.media.MediaTimestamp;
|
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
|
import com.google.android.exoplayer2.C;
|
||||||
|
import com.google.android.exoplayer2.Player;
|
||||||
|
import com.google.android.exoplayer2.PlaybackParameters;
|
||||||
|
import com.google.android.exoplayer2.SimpleExoPlayer;
|
||||||
|
import com.google.android.exoplayer2.source.ClippingMediaSource;
|
||||||
|
import com.google.android.exoplayer2.source.MediaSource;
|
||||||
|
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
|
||||||
|
import com.google.android.exoplayer2.upstream.DataSource;
|
||||||
|
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
|
||||||
|
import com.google.android.exoplayer2.util.Util;
|
||||||
import io.flutter.plugin.common.EventChannel;
|
import io.flutter.plugin.common.EventChannel;
|
||||||
import io.flutter.plugin.common.EventChannel.EventSink;
|
import io.flutter.plugin.common.EventChannel.EventSink;
|
||||||
import io.flutter.plugin.common.MethodCall;
|
import io.flutter.plugin.common.MethodCall;
|
||||||
|
@ -19,13 +22,14 @@ import java.io.IOException;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Deque;
|
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.net.Uri;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import sonic.Sonic;
|
|
||||||
|
|
||||||
public class AudioPlayer implements MethodCallHandler {
|
public class AudioPlayer implements MethodCallHandler, Player.EventListener {
|
||||||
private final Registrar registrar;
|
private final Registrar registrar;
|
||||||
|
private final Context context;
|
||||||
private final MethodChannel methodChannel;
|
private final MethodChannel methodChannel;
|
||||||
private final EventChannel eventChannel;
|
private final EventChannel eventChannel;
|
||||||
private EventSink eventSink;
|
private EventSink eventSink;
|
||||||
|
@ -44,33 +48,26 @@ public class AudioPlayer implements MethodCallHandler {
|
||||||
};
|
};
|
||||||
|
|
||||||
private final String id;
|
private final String id;
|
||||||
private String url;
|
|
||||||
private volatile PlaybackState state;
|
private volatile PlaybackState state;
|
||||||
private PlaybackState stateBeforeSeek;
|
private PlaybackState stateBeforeSeek;
|
||||||
private long updateTime;
|
private long updateTime;
|
||||||
private int updatePosition;
|
private long updatePosition;
|
||||||
private Deque<SeekRequest> seekRequests = new LinkedList<>();
|
|
||||||
|
|
||||||
private MediaExtractor extractor;
|
private long duration;
|
||||||
private MediaFormat format;
|
private Long start;
|
||||||
private Sonic sonic;
|
private Long end;
|
||||||
private int channelCount;
|
|
||||||
private int sampleRate;
|
|
||||||
private int duration;
|
|
||||||
private MediaCodec codec;
|
|
||||||
private AudioTrack audioTrack;
|
|
||||||
private PlayThread playThread;
|
|
||||||
private int start;
|
|
||||||
private Integer untilPosition;
|
|
||||||
private Object monitor = new Object();
|
|
||||||
private float volume = 1.0f;
|
private float volume = 1.0f;
|
||||||
private float speed = 1.0f;
|
private float speed = 1.0f;
|
||||||
private Thread mainThread;
|
private Long seekPos;
|
||||||
private byte[] chunk;
|
private Result prepareResult;
|
||||||
|
private Result seekResult;
|
||||||
|
private MediaSource mediaSource;
|
||||||
|
|
||||||
|
private SimpleExoPlayer player;
|
||||||
|
|
||||||
public AudioPlayer(final Registrar registrar, final String id) {
|
public AudioPlayer(final Registrar registrar, final String id) {
|
||||||
mainThread = Thread.currentThread();
|
|
||||||
this.registrar = registrar;
|
this.registrar = registrar;
|
||||||
|
this.context = registrar.activeContext();
|
||||||
this.id = id;
|
this.id = id;
|
||||||
methodChannel = new MethodChannel(registrar.messenger(), "com.ryanheise.just_audio.methods." + id);
|
methodChannel = new MethodChannel(registrar.messenger(), "com.ryanheise.just_audio.methods." + id);
|
||||||
methodChannel.setMethodCallHandler(this);
|
methodChannel.setMethodCallHandler(this);
|
||||||
|
@ -87,11 +84,47 @@ public class AudioPlayer implements MethodCallHandler {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
state = PlaybackState.none;
|
state = PlaybackState.none;
|
||||||
|
|
||||||
|
player = new SimpleExoPlayer.Builder(context).build();
|
||||||
|
player.addListener(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {
|
||||||
|
switch (playbackState) {
|
||||||
|
case Player.STATE_READY:
|
||||||
|
if (prepareResult != null) {
|
||||||
|
duration = player.getDuration();
|
||||||
|
prepareResult.success(duration);
|
||||||
|
prepareResult = null;
|
||||||
|
transition(PlaybackState.stopped);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Player.STATE_BUFFERING:
|
||||||
|
// TODO: use this instead of checkForDiscontinuity.
|
||||||
|
break;
|
||||||
|
case Player.STATE_ENDED:
|
||||||
|
if (state != PlaybackState.completed) {
|
||||||
|
transition(PlaybackState.completed);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSeekProcessed() {
|
||||||
|
if (seekResult != null) {
|
||||||
|
seekPos = null;
|
||||||
|
transition(stateBeforeSeek);
|
||||||
|
seekResult.success(null);
|
||||||
|
stateBeforeSeek = null;
|
||||||
|
seekResult = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkForDiscontinuity() {
|
private void checkForDiscontinuity() {
|
||||||
final long now = System.currentTimeMillis();
|
final long now = System.currentTimeMillis();
|
||||||
final int position = getCurrentPosition();
|
final long position = getCurrentPosition();
|
||||||
final long timeSinceLastUpdate = now - updateTime;
|
final long timeSinceLastUpdate = now - updateTime;
|
||||||
final long expectedPosition = updatePosition + (long)(timeSinceLastUpdate * speed);
|
final long expectedPosition = updatePosition + (long)(timeSinceLastUpdate * speed);
|
||||||
final long drift = position - expectedPosition;
|
final long drift = position - expectedPosition;
|
||||||
|
@ -114,8 +147,19 @@ public class AudioPlayer implements MethodCallHandler {
|
||||||
case "setUrl":
|
case "setUrl":
|
||||||
setUrl((String)args.get(0), result);
|
setUrl((String)args.get(0), result);
|
||||||
break;
|
break;
|
||||||
|
case "setClip":
|
||||||
|
Object start = args.get(0);
|
||||||
|
if (start != null && start instanceof Integer) {
|
||||||
|
start = new Long((Integer)start);
|
||||||
|
}
|
||||||
|
Object end = args.get(1);
|
||||||
|
if (end != null && end instanceof Integer) {
|
||||||
|
end = new Long((Integer)end);
|
||||||
|
}
|
||||||
|
setClip((Long)start, (Long)end, result);
|
||||||
|
break;
|
||||||
case "play":
|
case "play":
|
||||||
play((Integer)args.get(0));
|
play();
|
||||||
result.success(null);
|
result.success(null);
|
||||||
break;
|
break;
|
||||||
case "pause":
|
case "pause":
|
||||||
|
@ -134,7 +178,12 @@ public class AudioPlayer implements MethodCallHandler {
|
||||||
result.success(null);
|
result.success(null);
|
||||||
break;
|
break;
|
||||||
case "seek":
|
case "seek":
|
||||||
seek((Integer)args.get(0), result);
|
Object position = args.get(0);
|
||||||
|
if (position instanceof Integer) {
|
||||||
|
seek((Integer)position, result);
|
||||||
|
} else {
|
||||||
|
seek((Long)position, result);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "dispose":
|
case "dispose":
|
||||||
dispose();
|
dispose();
|
||||||
|
@ -161,13 +210,13 @@ public class AudioPlayer implements MethodCallHandler {
|
||||||
eventSink.success(event);
|
eventSink.success(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
private int getCurrentPosition() {
|
private long getCurrentPosition() {
|
||||||
if (state == PlaybackState.none || state == PlaybackState.connecting) {
|
if (state == PlaybackState.none || state == PlaybackState.connecting) {
|
||||||
return 0;
|
return 0;
|
||||||
} else if (seekRequests.size() > 0) {
|
} else if (seekPos != null) {
|
||||||
return seekRequests.peekFirst().pos;
|
return seekPos;
|
||||||
} else {
|
} else {
|
||||||
return (int)(extractor.getSampleTime() / 1000);
|
return player.getCurrentPosition();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -183,92 +232,47 @@ public class AudioPlayer implements MethodCallHandler {
|
||||||
broadcastPlaybackEvent();
|
broadcastPlaybackEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void bgTransition(final PlaybackState newState) {
|
|
||||||
bgTransition(state, newState);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void bgTransition(final PlaybackState oldState, final PlaybackState newState) {
|
|
||||||
// Redundant assignment which ensures the state is set
|
|
||||||
// immediately in the background thread.
|
|
||||||
state = newState;
|
|
||||||
handler.post(new Runnable() {
|
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
transition(oldState, newState);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUrl(final String url, final Result result) throws IOException {
|
public void setUrl(final String url, final Result result) throws IOException {
|
||||||
if (state != PlaybackState.none && state != PlaybackState.stopped && state != PlaybackState.completed) {
|
prepareResult = result;
|
||||||
throw new IllegalStateException("Can call setUrl only from none/stopped/completed states (" + state + ")");
|
|
||||||
}
|
|
||||||
ensureStopped();
|
|
||||||
transition(PlaybackState.connecting);
|
transition(PlaybackState.connecting);
|
||||||
this.url = url;
|
DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(context, Util.getUserAgent(context, "just_audio"));
|
||||||
if (extractor != null) {
|
mediaSource = new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(Uri.parse(url));
|
||||||
extractor.release();
|
player.prepare(mediaSource);
|
||||||
}
|
|
||||||
new Thread(() -> {
|
|
||||||
try {
|
|
||||||
blockingInitExtractorAndCodec();
|
|
||||||
|
|
||||||
sonic = new Sonic(sampleRate, channelCount);
|
|
||||||
sonic.setVolume(volume);
|
|
||||||
sonic.setSpeed(speed);
|
|
||||||
|
|
||||||
bgTransition(PlaybackState.stopped);
|
|
||||||
handler.post(() -> result.success(duration));
|
|
||||||
} catch (Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
handler.post(() -> result.error("Error: " + e, null, null));
|
|
||||||
}
|
|
||||||
}).start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void play(final Integer untilPosition) {
|
public void setClip(final Long start, final Long end, final Result result) {
|
||||||
if (untilPosition != null && untilPosition <= start) {
|
this.start = start;
|
||||||
throw new IllegalArgumentException("untilPosition must be >= 0");
|
this.end = end;
|
||||||
|
prepareResult = result;
|
||||||
|
if (start != null || end != null) {
|
||||||
|
player.prepare(new ClippingMediaSource(mediaSource,
|
||||||
|
(start != null ? start : 0) * 1000L,
|
||||||
|
(end != null ? end : C.TIME_END_OF_SOURCE) * 1000L));
|
||||||
|
} else {
|
||||||
|
player.prepare(mediaSource);
|
||||||
}
|
}
|
||||||
this.untilPosition = untilPosition;
|
}
|
||||||
|
|
||||||
|
public void play() {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case stopped:
|
case stopped:
|
||||||
case completed:
|
case completed:
|
||||||
ensureStopped();
|
case buffering:
|
||||||
transition(PlaybackState.playing);
|
|
||||||
playThread = new PlayThread();
|
|
||||||
playThread.start();
|
|
||||||
break;
|
|
||||||
case paused:
|
case paused:
|
||||||
synchronized (monitor) {
|
player.setPlayWhenReady(true);
|
||||||
transition(PlaybackState.playing);
|
transition(PlaybackState.playing);
|
||||||
monitor.notifyAll();
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new IllegalStateException("Can call play only from stopped, completed and paused states (" + state + ")");
|
throw new IllegalStateException("Can call play only from stopped, completed and paused states (" + state + ")");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ensureStopped() {
|
|
||||||
synchronized (monitor) {
|
|
||||||
try {
|
|
||||||
while (playThread != null) {
|
|
||||||
monitor.wait();
|
|
||||||
}
|
|
||||||
} catch (Exception e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void pause() {
|
public void pause() {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case playing:
|
case playing:
|
||||||
case buffering:
|
case buffering:
|
||||||
synchronized (monitor) {
|
player.setPlayWhenReady(false);
|
||||||
transition(PlaybackState.paused);
|
transition(PlaybackState.paused);
|
||||||
audioTrack.pause();
|
|
||||||
monitor.notifyAll();
|
|
||||||
}
|
|
||||||
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 + ")");
|
||||||
|
@ -280,29 +284,14 @@ public class AudioPlayer implements MethodCallHandler {
|
||||||
case stopped:
|
case stopped:
|
||||||
result.success(null);
|
result.success(null);
|
||||||
break;
|
break;
|
||||||
case completed:
|
|
||||||
transition(PlaybackState.stopped);
|
|
||||||
result.success(null);
|
|
||||||
break;
|
|
||||||
// TODO: Allow stopping from buffered/connecting states.
|
// TODO: Allow stopping from buffered/connecting states.
|
||||||
|
case completed:
|
||||||
case playing:
|
case playing:
|
||||||
case paused:
|
case paused:
|
||||||
synchronized (monitor) {
|
player.setPlayWhenReady(false);
|
||||||
// It takes some time for the PlayThread to actually wind down
|
player.seekTo(0L);
|
||||||
// so other methods that transition from the stopped state should
|
transition(PlaybackState.stopped);
|
||||||
// wait for playThread == null with ensureStopped().
|
result.success(null);
|
||||||
PlaybackState oldState = state;
|
|
||||||
transition(PlaybackState.stopped);
|
|
||||||
if (oldState == PlaybackState.paused) {
|
|
||||||
monitor.notifyAll();
|
|
||||||
} else if (audioTrack != null) {
|
|
||||||
audioTrack.pause();
|
|
||||||
}
|
|
||||||
new Thread(() -> {
|
|
||||||
ensureStopped();
|
|
||||||
handler.post(() -> result.success(null));
|
|
||||||
}).start();
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new IllegalStateException("Can call stop only from playing/paused/stopped/completed states (" + state + ")");
|
throw new IllegalStateException("Can call stop only from playing/paused/stopped/completed states (" + state + ")");
|
||||||
|
@ -311,104 +300,32 @@ public class AudioPlayer implements MethodCallHandler {
|
||||||
|
|
||||||
public void setVolume(final float volume) {
|
public void setVolume(final float volume) {
|
||||||
this.volume = volume;
|
this.volume = volume;
|
||||||
if (sonic != null) {
|
player.setVolume(volume);
|
||||||
sonic.setVolume(volume);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setSpeed(final float speed) {
|
public void setSpeed(final float speed) {
|
||||||
// NOTE: existing audio data in the pipeline will continue
|
|
||||||
// to play out at the speed it was already processed at. So
|
|
||||||
// for a brief moment, checkForDiscontinuity() may erroneously
|
|
||||||
// detect some buffering.
|
|
||||||
// TODO: Sort this out. The cheap workaround would be to disable
|
|
||||||
// checks for discontinuity during this brief moment.
|
|
||||||
this.speed = speed;
|
this.speed = speed;
|
||||||
if (sonic != null) {
|
player.setPlaybackParameters(new PlaybackParameters(speed));
|
||||||
sonic.setSpeed(speed);
|
|
||||||
}
|
|
||||||
broadcastPlaybackEvent();
|
broadcastPlaybackEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Test whether this times out the MediaCodec on Ogg files.
|
public void seek(final long position, final Result result) {
|
||||||
// See: https://stackoverflow.com/questions/22109050/mediacodec-dequeueoutputbuffer-times-out-when-seeking-with-ogg-audio-files
|
seekPos = position;
|
||||||
public void seek(final int position, final Result result) {
|
seekResult = result;
|
||||||
synchronized (monitor) {
|
if (stateBeforeSeek == null) {
|
||||||
if (state == PlaybackState.none || state == PlaybackState.connecting) {
|
stateBeforeSeek = state;
|
||||||
throw new IllegalStateException("Cannot call seek in none or connecting states (" + state + ")");
|
|
||||||
}
|
|
||||||
if (state == PlaybackState.stopped) {
|
|
||||||
ensureStopped();
|
|
||||||
}
|
|
||||||
start = position;
|
|
||||||
if (seekRequests.size() == 0) {
|
|
||||||
stateBeforeSeek = state;
|
|
||||||
}
|
|
||||||
seekRequests.addLast(new SeekRequest(position, result));
|
|
||||||
handler.removeCallbacks(positionObserver);
|
|
||||||
transition(PlaybackState.buffering);
|
|
||||||
if (stateBeforeSeek == PlaybackState.stopped) {
|
|
||||||
new Thread(() -> {
|
|
||||||
processSeekRequests();
|
|
||||||
}).start();
|
|
||||||
} else {
|
|
||||||
monitor.notifyAll();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
handler.removeCallbacks(positionObserver);
|
||||||
|
transition(PlaybackState.buffering);
|
||||||
|
player.seekTo(position);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void dispose() {
|
public void dispose() {
|
||||||
if (state != PlaybackState.stopped && state != PlaybackState.completed && state != PlaybackState.none) {
|
if (state != PlaybackState.stopped && state != PlaybackState.completed && state != PlaybackState.none) {
|
||||||
throw new IllegalStateException("Can call dispose only from stopped/completed/none states (" + state + ")");
|
throw new IllegalStateException("Can call dispose only from stopped/completed/none states (" + state + ")");
|
||||||
}
|
}
|
||||||
if (extractor != null) {
|
player.release();
|
||||||
ensureStopped();
|
transition(PlaybackState.none);
|
||||||
transition(PlaybackState.none);
|
|
||||||
extractor.release();
|
|
||||||
extractor = null;
|
|
||||||
codec.stop();
|
|
||||||
codec.release();
|
|
||||||
codec = null;
|
|
||||||
chunk = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void blockingInitExtractorAndCodec() throws IOException {
|
|
||||||
extractor = new MediaExtractor();
|
|
||||||
extractor.setDataSource(url);
|
|
||||||
format = selectAudioTrack(extractor);
|
|
||||||
channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
|
|
||||||
sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE);
|
|
||||||
long durationMs = format.getLong(MediaFormat.KEY_DURATION);
|
|
||||||
duration = (int)(durationMs / 1000);
|
|
||||||
start = 0;
|
|
||||||
codec = MediaCodec.createDecoderByType(format.getString(MediaFormat.KEY_MIME));
|
|
||||||
codec.configure(format, null, null, 0);
|
|
||||||
codec.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
private MediaFormat selectAudioTrack(MediaExtractor extractor) throws IOException {
|
|
||||||
int trackCount = extractor.getTrackCount();
|
|
||||||
for (int i = 0; i < trackCount; i++) {
|
|
||||||
MediaFormat format = extractor.getTrackFormat(i);
|
|
||||||
if (format.getString(MediaFormat.KEY_MIME).startsWith("audio/")) {
|
|
||||||
extractor.selectTrack(i);
|
|
||||||
return format;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new RuntimeException("No audio track found");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void processSeekRequests() {
|
|
||||||
while (seekRequests.size() > 0) {
|
|
||||||
SeekRequest seekRequest = seekRequests.removeFirst();
|
|
||||||
extractor.seekTo(seekRequest.pos*1000L, MediaExtractor.SEEK_TO_CLOSEST_SYNC);
|
|
||||||
if (seekRequests.size() == 0) {
|
|
||||||
bgTransition(stateBeforeSeek);
|
|
||||||
stateBeforeSeek = null;
|
|
||||||
}
|
|
||||||
handler.post(() -> seekRequest.result.success(null));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startObservingPosition() {
|
private void startObservingPosition() {
|
||||||
|
@ -416,208 +333,6 @@ public class AudioPlayer implements MethodCallHandler {
|
||||||
handler.post(positionObserver);
|
handler.post(positionObserver);
|
||||||
}
|
}
|
||||||
|
|
||||||
private class PlayThread extends Thread {
|
|
||||||
private static final int TIMEOUT = 1000;
|
|
||||||
private static final int FRAME_SIZE = 1024*2;
|
|
||||||
private static final int BEHIND_LIMIT = 500; // ms
|
|
||||||
private byte[] silence;
|
|
||||||
private boolean finishedDecoding = false;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
boolean reachedEnd = false;
|
|
||||||
int encoding = AudioFormat.ENCODING_PCM_16BIT;
|
|
||||||
int channelFormat = channelCount==1?AudioFormat.CHANNEL_OUT_MONO:AudioFormat.CHANNEL_OUT_STEREO;
|
|
||||||
int minSize = AudioTrack.getMinBufferSize(sampleRate, channelFormat, encoding);
|
|
||||||
int audioTrackBufferSize = minSize * 4;
|
|
||||||
audioTrack = new AudioTrack(
|
|
||||||
AudioManager.STREAM_MUSIC,
|
|
||||||
sampleRate,
|
|
||||||
channelFormat,
|
|
||||||
encoding,
|
|
||||||
audioTrackBufferSize,
|
|
||||||
AudioTrack.MODE_STREAM);
|
|
||||||
|
|
||||||
silence = new byte[audioTrackBufferSize];
|
|
||||||
|
|
||||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
|
||||||
boolean firstSample = true;
|
|
||||||
int decoderIdleCount = 0;
|
|
||||||
boolean finishedReading = false;
|
|
||||||
int progress = 0;
|
|
||||||
byte[] sonicOut = new byte[audioTrackBufferSize];
|
|
||||||
try {
|
|
||||||
audioTrack.play();
|
|
||||||
while (!finishedDecoding) {
|
|
||||||
|
|
||||||
if (checkForRequest()) continue;
|
|
||||||
|
|
||||||
// put data into input buffer
|
|
||||||
|
|
||||||
if (!finishedReading) {
|
|
||||||
int inputBufferIndex = codec.dequeueInputBuffer(TIMEOUT);
|
|
||||||
if (inputBufferIndex >= 0) {
|
|
||||||
ByteBuffer inputBuffer = codec.getInputBuffer(inputBufferIndex);
|
|
||||||
long presentationTime = extractor.getSampleTime();
|
|
||||||
int presentationTimeMs = (int)(presentationTime / 1000);
|
|
||||||
int sampleSize = extractor.readSampleData(inputBuffer, 0);
|
|
||||||
if (firstSample && sampleSize == 2 && format.getString(MediaFormat.KEY_MIME).equals("audio/mp4a-latm")) {
|
|
||||||
// Skip initial frames.
|
|
||||||
extractor.advance();
|
|
||||||
} else if (sampleSize >= 0) {
|
|
||||||
codec.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTime, 0);
|
|
||||||
extractor.advance();
|
|
||||||
} else {
|
|
||||||
codec.queueInputBuffer(inputBufferIndex, 0, 0, -1, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
|
|
||||||
finishedReading = true;
|
|
||||||
}
|
|
||||||
firstSample = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checkForRequest()) continue;
|
|
||||||
|
|
||||||
// read data from output buffer
|
|
||||||
|
|
||||||
int outputBufferIndex = codec.dequeueOutputBuffer(info, TIMEOUT);
|
|
||||||
decoderIdleCount++;
|
|
||||||
if (outputBufferIndex >= 0) {
|
|
||||||
int currentPosition = (int)(info.presentationTimeUs/1000);
|
|
||||||
ByteBuffer buf = codec.getOutputBuffer(outputBufferIndex);
|
|
||||||
if (info.size > 0) {
|
|
||||||
decoderIdleCount = 0;
|
|
||||||
|
|
||||||
if (chunk == null || chunk.length < info.size) {
|
|
||||||
chunk = new byte[info.size];
|
|
||||||
}
|
|
||||||
buf.get(chunk, 0, info.size);
|
|
||||||
buf.clear();
|
|
||||||
|
|
||||||
// put decoded data into sonic
|
|
||||||
|
|
||||||
if (chunk.length > 0) {
|
|
||||||
sonic.writeBytesToStream(chunk, chunk.length);
|
|
||||||
} else {
|
|
||||||
sonic.flushStream();
|
|
||||||
}
|
|
||||||
|
|
||||||
// output sonic'd data to audioTrack
|
|
||||||
|
|
||||||
int numWritten;
|
|
||||||
do {
|
|
||||||
numWritten = sonic.readBytesFromStream(sonicOut, sonicOut.length);
|
|
||||||
if (numWritten > 0) {
|
|
||||||
audioTrack.write(sonicOut, 0, numWritten);
|
|
||||||
}
|
|
||||||
} while (numWritten > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect end of playback
|
|
||||||
|
|
||||||
codec.releaseOutputBuffer(outputBufferIndex, false);
|
|
||||||
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
|
|
||||||
if (untilPosition != null) {
|
|
||||||
extractor.release();
|
|
||||||
codec.flush();
|
|
||||||
codec.stop();
|
|
||||||
codec.release();
|
|
||||||
blockingInitExtractorAndCodec();
|
|
||||||
finishedReading = false;
|
|
||||||
finishedDecoding = false;
|
|
||||||
decoderIdleCount = 0;
|
|
||||||
audioTrack.pause();
|
|
||||||
bgTransition(PlaybackState.paused);
|
|
||||||
} else {
|
|
||||||
audioTrack.pause();
|
|
||||||
finishedDecoding = true;
|
|
||||||
reachedEnd = true;
|
|
||||||
}
|
|
||||||
} else if (untilPosition != null && currentPosition >= untilPosition) {
|
|
||||||
// NOTE: When streaming audio over bluetooth, it clips off
|
|
||||||
// the last 200-300ms of the clip, even though it has been
|
|
||||||
// written to the AudioTrack. So, we need an option to pad the
|
|
||||||
// audio with an extra 200-300ms of silence.
|
|
||||||
// Could be a good idea to do the same at the start
|
|
||||||
// since some bluetooth headphones fade in and miss the
|
|
||||||
// first little bit.
|
|
||||||
|
|
||||||
Arrays.fill(sonicOut, (byte)0);
|
|
||||||
audioTrack.write(sonicOut, 0, sonicOut.length);
|
|
||||||
bgTransition(PlaybackState.paused);
|
|
||||||
audioTrack.pause();
|
|
||||||
}
|
|
||||||
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
|
||||||
// Don't expect this to happen in audio files, but could be wrong.
|
|
||||||
// TODO: Investigate.
|
|
||||||
//MediaFormat newFormat = codec.getOutputFormat();
|
|
||||||
}
|
|
||||||
if (decoderIdleCount >= 100) {
|
|
||||||
// Data has stopped coming through the pipeline despite not receiving a
|
|
||||||
// BUFFER_FLAG_END_OF_STREAM signal, so stop.
|
|
||||||
System.out.println("decoderIdleCount >= 100. finishedDecoding = true");
|
|
||||||
finishedDecoding = true;
|
|
||||||
audioTrack.pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
} finally {
|
|
||||||
codec.flush();
|
|
||||||
audioTrack.flush();
|
|
||||||
audioTrack.release();
|
|
||||||
audioTrack = null;
|
|
||||||
synchronized (monitor) {
|
|
||||||
start = 0;
|
|
||||||
untilPosition = null;
|
|
||||||
bgTransition(reachedEnd ? PlaybackState.completed : PlaybackState.stopped);
|
|
||||||
extractor.seekTo(0L, MediaExtractor.SEEK_TO_CLOSEST_SYNC);
|
|
||||||
handler.post(() -> broadcastPlaybackEvent());
|
|
||||||
playThread = null;
|
|
||||||
monitor.notifyAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return true to "continue" to the audio loop
|
|
||||||
private boolean checkForRequest() {
|
|
||||||
try {
|
|
||||||
synchronized (monitor) {
|
|
||||||
if (state == PlaybackState.paused) {
|
|
||||||
while (state == PlaybackState.paused) {
|
|
||||||
monitor.wait();
|
|
||||||
}
|
|
||||||
// Unpaused
|
|
||||||
// Reset updateTime for higher accuracy.
|
|
||||||
bgTransition(state);
|
|
||||||
if (state == PlaybackState.playing) {
|
|
||||||
audioTrack.play();
|
|
||||||
} else if (state == PlaybackState.buffering) {
|
|
||||||
// TODO: What if we are in the second checkForRequest call and
|
|
||||||
// we ask to continue the loop, we may forget about dequeued
|
|
||||||
// input buffers. Need to handle this correctly.
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if (state == PlaybackState.buffering && seekRequests.size() > 0) {
|
|
||||||
// Seek requested
|
|
||||||
codec.flush();
|
|
||||||
audioTrack.flush();
|
|
||||||
processSeekRequests();
|
|
||||||
if (state != PlaybackState.stopped) {
|
|
||||||
// The == stopped case is handled below.
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (state == PlaybackState.stopped) {
|
|
||||||
finishedDecoding = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception e) {}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PlaybackState {
|
enum PlaybackState {
|
||||||
none,
|
none,
|
||||||
stopped,
|
stopped,
|
||||||
|
@ -627,14 +342,4 @@ public class AudioPlayer implements MethodCallHandler {
|
||||||
connecting,
|
connecting,
|
||||||
completed
|
completed
|
||||||
}
|
}
|
||||||
|
|
||||||
class SeekRequest {
|
|
||||||
public final int pos;
|
|
||||||
public final Result result;
|
|
||||||
|
|
||||||
public SeekRequest(int pos, Result result) {
|
|
||||||
this.pos = pos;
|
|
||||||
this.result = result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -48,8 +48,11 @@
|
||||||
NSArray* args = (NSArray*)call.arguments;
|
NSArray* args = (NSArray*)call.arguments;
|
||||||
if ([@"setUrl" isEqualToString:call.method]) {
|
if ([@"setUrl" isEqualToString:call.method]) {
|
||||||
[self setUrl:args[0] result:result];
|
[self setUrl:args[0] result:result];
|
||||||
|
} else if ([@"setClip" isEqualToString:call.method]) {
|
||||||
|
[self setClip:args[0] end:args[1]];
|
||||||
|
result(nil);
|
||||||
} else if ([@"play" isEqualToString:call.method]) {
|
} else if ([@"play" isEqualToString:call.method]) {
|
||||||
[self play:args[0]];
|
[self play];
|
||||||
result(nil);
|
result(nil);
|
||||||
} else if ([@"pause" isEqualToString:call.method]) {
|
} else if ([@"pause" isEqualToString:call.method]) {
|
||||||
[self pause];
|
[self pause];
|
||||||
|
@ -215,13 +218,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)play:(NSNumber*)untilPosition {
|
- (void)setClip:(NSNumber*)start end:(NSNumber*)end {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)play {
|
||||||
// TODO: dynamically adjust the lag.
|
// TODO: dynamically adjust the lag.
|
||||||
//int lag = 6;
|
//int lag = 6;
|
||||||
int start = [self getCurrentPosition];
|
//int start = [self getCurrentPosition];
|
||||||
if (untilPosition != [NSNull null] && [untilPosition intValue] <= start) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
[_player play];
|
[_player play];
|
||||||
[self setPlaybackState:playing];
|
[self setPlaybackState:playing];
|
||||||
// TODO: convert this Android code to iOS
|
// TODO: convert this Android code to iOS
|
||||||
|
|
|
@ -13,9 +13,11 @@ import 'package:rxdart/rxdart.dart';
|
||||||
/// final player = AudioPlayer();
|
/// final player = AudioPlayer();
|
||||||
/// await player.setUrl('https://foo.com/bar.mp3');
|
/// await player.setUrl('https://foo.com/bar.mp3');
|
||||||
/// player.play();
|
/// player.play();
|
||||||
/// await player.pause();
|
/// player.pause();
|
||||||
/// await player.play(untilPosition: Duration(minutes: 1));
|
/// player.play();
|
||||||
/// await player.stop()
|
/// await player.stop();
|
||||||
|
/// await player.setClip(start: Duration(seconds: 10), end: Duration(seconds: 20));
|
||||||
|
/// await player.play();
|
||||||
/// await player.setUrl('https://foo.com/baz.mp3');
|
/// await player.setUrl('https://foo.com/baz.mp3');
|
||||||
/// await player.seek(Duration(minutes: 5));
|
/// await player.seek(Duration(minutes: 5));
|
||||||
/// player.play();
|
/// player.play();
|
||||||
|
@ -171,27 +173,38 @@ class AudioPlayer {
|
||||||
Future<File> get _cacheFile async => File(p.join(
|
Future<File> get _cacheFile async => File(p.join(
|
||||||
(await getTemporaryDirectory()).path, 'just_audio_asset_cache', '$_id'));
|
(await getTemporaryDirectory()).path, 'just_audio_asset_cache', '$_id'));
|
||||||
|
|
||||||
/// Plays the currently loaded media from the current position, until the
|
/// Clip the audio to the given [start] and [end] timestamps.
|
||||||
/// given position if specified. The [Future] returned by this method
|
Future<Duration> setClip({Duration start, Duration end}) async {
|
||||||
/// completes when playback completes or is paused or stopped. It is legal to
|
_durationFuture =
|
||||||
/// invoke this method only from one of the following states:
|
_invokeMethod('setClip', [start?.inMilliseconds, end?.inMilliseconds])
|
||||||
|
.then((ms) => Duration(milliseconds: ms));
|
||||||
|
final duration = await _durationFuture;
|
||||||
|
_durationSubject.add(duration);
|
||||||
|
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. It is legal to invoke this method only from one of the following
|
||||||
|
/// states:
|
||||||
///
|
///
|
||||||
/// * [AudioPlaybackState.stopped]
|
/// * [AudioPlaybackState.stopped]
|
||||||
/// * [AudioPlaybackState.completed]
|
/// * [AudioPlaybackState.completed]
|
||||||
/// * [AudioPlaybackState.paused]
|
/// * [AudioPlaybackState.paused]
|
||||||
Future<void> play({final Duration untilPosition}) async {
|
Future<void> play() async {
|
||||||
StreamSubscription subscription;
|
StreamSubscription subscription;
|
||||||
Completer completer = Completer();
|
Completer completer = Completer();
|
||||||
subscription = playbackStateStream
|
subscription = playbackStateStream
|
||||||
.skip(1)
|
.skip(1)
|
||||||
.where((state) =>
|
.where((state) =>
|
||||||
state == AudioPlaybackState.paused ||
|
state == AudioPlaybackState.paused ||
|
||||||
state == AudioPlaybackState.stopped)
|
state == AudioPlaybackState.stopped ||
|
||||||
|
state == AudioPlaybackState.completed)
|
||||||
.listen((state) {
|
.listen((state) {
|
||||||
subscription.cancel();
|
subscription.cancel();
|
||||||
completer.complete();
|
completer.complete();
|
||||||
});
|
});
|
||||||
await _invokeMethod('play', [untilPosition?.inMilliseconds]);
|
await _invokeMethod('play');
|
||||||
await completer.future;
|
await completer.future;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue