Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add MappingAudioSource for just-in-time audio source creation #779

Open
wants to merge 8 commits into
base: minor
Choose a base branch
from
1 change: 1 addition & 0 deletions just_audio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ Please also consider pressing the thumbs up button at the top of [this page](htt
| looping/shuffling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| compose audio | ✅ | ✅ | ✅ | ✅ | | ✅ |
| gapless playback | ✅ | ✅ | ✅ | | ✅ | ✅ |
| mapping audio sources | ✅ | | | ✅ | | |
| report player errors | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| handle phonecall interruptions | ✅ | ✅ | | | | |
| buffering/loading options | ✅ | ✅ | ✅ | | | |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import com.google.android.exoplayer2.metadata.icy.IcyInfo;
import com.google.android.exoplayer2.source.ClippingMediaSource;
import com.google.android.exoplayer2.source.ConcatenatingMediaSource;
import com.google.android.exoplayer2.source.MaskingMediaSource;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.source.ShuffleOrder;
Expand Down Expand Up @@ -62,7 +63,7 @@
import java.util.Map;
import java.util.Random;

public class AudioPlayer implements MethodCallHandler, Player.Listener, MetadataOutput {
public class AudioPlayer implements MethodCallHandler, Player.Listener, MetadataOutput, LazyMediaSourceProvider {

static final String TAG = "AudioPlayer";

Expand Down Expand Up @@ -611,6 +612,17 @@ private MediaSource decodeAudioSource(final Object json) {
.setUri(Uri.parse((String)map.get("uri")))
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build());
case "mapping":
return new MaskingMediaSource(
new LazyMediaSource(
this,
id,
new MediaItem.Builder()
.setTag(id)
.build()
),
true
);
case "silence":
return new SilenceMediaSource.Factory()
.setDurationUs(getLong(map.get("duration")))
Expand Down Expand Up @@ -695,6 +707,29 @@ private DataSource.Factory buildDataSourceFactory() {
return new DefaultDataSource.Factory(context, httpDataSourceFactory);
}

@Override
public void createMediaSource(String id, LazyMediaSourceReceiver receiver) {
handler.post(() -> {
methodChannel.invokeMethod("createMappedAudioSourceSource", Collections.singletonMap("id", id), new Result() {
@Override
public void success(Object json) {
final MediaSource mediaSource = json == null ? null : decodeAudioSource(json);
receiver.onMediaSourceCreated(mediaSource);
}

@Override
public void error(String errorCode, String errorMessage, Object errorDetails) {
throw new IllegalStateException("createMappedAudioSourceSource failed. Cannot proceed. (" + errorCode + ", " + errorMessage + ", " + errorDetails + ")");
}

@Override
public void notImplemented() {
throw new IllegalArgumentException("createMappedAudioSourceSource is not implemented by the platform.");
}
});
});
}

private void load(final MediaSource mediaSource, final long initialPosition, final Integer initialIndex, final Result result) {
this.initialPos = initialPosition;
this.initialIndex = initialIndex;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package com.ryanheise.just_audio;

import android.os.Handler;

import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.analytics.PlayerId;
import com.google.android.exoplayer2.drm.DrmSessionEventListener;
import com.google.android.exoplayer2.source.MediaPeriod;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.MediaSourceEventListener;
import com.google.android.exoplayer2.source.SilenceMediaSource;
import com.google.android.exoplayer2.upstream.Allocator;
import com.google.android.exoplayer2.upstream.TransferListener;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/**
* A {@link MediaSource} that lazily defers to another {@link MediaSource} when it is required.
* <p>
* This {@link MediaSource} must be used with a {@link com.google.android.exoplayer2.source.MaskingMediaSource}.
*/
class LazyMediaSource implements MediaSource {
private final LazyMediaSourceProvider mediaSourceProvider;
public final String id;
public final MediaItem placeholderMediaItem;

private final Map<MediaSourceEventListener, Handler> pendingEventListeners = new HashMap<>();
private final Map<DrmSessionEventListener, Handler> pendingDrmEventListeners = new HashMap<>();

private MediaSource mediaSource;

LazyMediaSource(LazyMediaSourceProvider mediaSourceProvider, String id, MediaItem placeholderMediaItem) {
this.mediaSourceProvider = mediaSourceProvider;
this.id = id;
this.placeholderMediaItem = placeholderMediaItem;
}

@Override
public void addEventListener(Handler handler, MediaSourceEventListener eventListener) {
if (mediaSource == null) {
pendingEventListeners.put(eventListener, handler);
} else {
mediaSource.addEventListener(handler, eventListener);
}
}

@Override
public void removeEventListener(MediaSourceEventListener eventListener) {
if (mediaSource == null) {
pendingEventListeners.remove(eventListener);
} else {
mediaSource.removeEventListener(eventListener);
}
}

@Override
public void addDrmEventListener(Handler handler, DrmSessionEventListener eventListener) {
if (mediaSource == null) {
pendingDrmEventListeners.put(eventListener, handler);
} else {
mediaSource.addDrmEventListener(handler, eventListener);
}
}

@Override
public void removeDrmEventListener(DrmSessionEventListener eventListener) {
if (mediaSource == null) {
pendingDrmEventListeners.remove(eventListener);
} else {
mediaSource.removeDrmEventListener(eventListener);
}
}

@Override
public Timeline getInitialTimeline() {
if (mediaSource == null) return null;
return mediaSource.getInitialTimeline();
}

@Override
public boolean isSingleWindow() {
if (mediaSource == null) return false;
return mediaSource.isSingleWindow();
}

@Override
public MediaItem getMediaItem() {
if (mediaSource == null) {
return placeholderMediaItem;
} else {
return mediaSource.getMediaItem();
}
}

@Override
public void prepareSource(
MediaSourceCaller caller,
TransferListener mediaTransferListener,
PlayerId playerId
) {
mediaSourceProvider.createMediaSource(id, (mediaSource) -> {
if (mediaSource == null) {
this.mediaSource = new SilenceMediaSource(0);
} else {
this.mediaSource = mediaSource;
}

for (Map.Entry<MediaSourceEventListener, Handler> entry : pendingEventListeners.entrySet()) {
this.mediaSource.addEventListener(entry.getValue(), entry.getKey());
}
pendingEventListeners.clear();
for (Map.Entry<DrmSessionEventListener, Handler> entry : pendingDrmEventListeners.entrySet()) {
this.mediaSource.addDrmEventListener(entry.getValue(), entry.getKey());
}
pendingDrmEventListeners.clear();
this.mediaSource.prepareSource(caller, mediaTransferListener, playerId);
});
}

@Override
public void maybeThrowSourceInfoRefreshError() throws IOException {
if (mediaSource == null) return;
mediaSource.maybeThrowSourceInfoRefreshError();
}

@Override
public void enable(MediaSourceCaller caller) {
if (mediaSource == null) throw new IllegalStateException();
mediaSource.enable(caller);
}

@Override
public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) {
if (mediaSource == null) throw new IllegalStateException();
return mediaSource.createPeriod(id, allocator, startPositionUs);
}

@Override
public void releasePeriod(MediaPeriod mediaPeriod) {
if (mediaSource == null) throw new IllegalStateException();
mediaSource.releasePeriod(mediaPeriod);
}

@Override
public void disable(MediaSourceCaller caller) {
if (mediaSource == null) throw new IllegalStateException();
mediaSource.disable(caller);
}

@Override
public void releaseSource(MediaSourceCaller caller) {
if (mediaSource == null) return;
mediaSource.releaseSource(caller);
}
}

interface LazyMediaSourceReceiver {
void onMediaSourceCreated(MediaSource mediaSource);
}

interface LazyMediaSourceProvider {
void createMediaSource(String id, LazyMediaSourceReceiver receiver);
}
64 changes: 58 additions & 6 deletions just_audio/lib/just_audio.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,11 @@ class AudioPlayer {
final platform = active
? await (_nativePlatform = _pluginPlatform.init(InitRequest(
id: _id,
getAudioSourceMessage: (id) {
assert(_audioSources.containsKey(id),
'Audio source with ID $id does not exist!');
return _audioSources[id]!._toMessage();
},
audioLoadConfiguration: _audioLoadConfiguration?._toMessage(),
androidAudioEffects: (_isAndroid() || _isUnitTest())
? _audioPipeline.androidAudioEffects
Expand Down Expand Up @@ -2144,6 +2149,9 @@ abstract class IndexedAudioSource extends AudioSource {
@override
void _shuffle({int? initialIndex}) {}

@override
IndexedAudioSourceMessage _toMessage();

@override
List<IndexedAudioSource> get sequence => [this];

Expand Down Expand Up @@ -2245,7 +2253,7 @@ class ProgressiveAudioSource extends UriAudioSource {
: super(uri, headers: headers, tag: tag, duration: duration);

@override
AudioSourceMessage _toMessage() => ProgressiveAudioSourceMessage(
IndexedAudioSourceMessage _toMessage() => ProgressiveAudioSourceMessage(
id: _id, uri: _effectiveUri.toString(), headers: headers, tag: tag);
}

Expand All @@ -2269,7 +2277,7 @@ class DashAudioSource extends UriAudioSource {
: super(uri, headers: headers, tag: tag, duration: duration);

@override
AudioSourceMessage _toMessage() => DashAudioSourceMessage(
IndexedAudioSourceMessage _toMessage() => DashAudioSourceMessage(
id: _id, uri: _effectiveUri.toString(), headers: headers, tag: tag);
}

Expand All @@ -2292,7 +2300,7 @@ class HlsAudioSource extends UriAudioSource {
: super(uri, headers: headers, tag: tag, duration: duration);

@override
AudioSourceMessage _toMessage() => HlsAudioSourceMessage(
IndexedAudioSourceMessage _toMessage() => HlsAudioSourceMessage(
id: _id, uri: _effectiveUri.toString(), headers: headers, tag: tag);
}

Expand All @@ -2312,10 +2320,54 @@ class SilenceAudioSource extends IndexedAudioSource {
}) : super(tag: tag, duration: duration);

@override
AudioSourceMessage _toMessage() =>
IndexedAudioSourceMessage _toMessage() =>
SilenceAudioSourceMessage(id: _id, duration: duration);
}

/// An [AudioSource] that maps to another [AudioSource] when it is first loaded.
///
/// This is useful, for example, inside a [ConcatenatingAudioSource], if an
/// audio URL cannot be loaded until just before it is played.
///
/// Note that [AudioSource]s are not currently disposed of until the
/// [AudioPlayer] completes. It is recommended to keep the [identifier] and
/// [createAudioSource] values light - the [identifier], for example, could be a
/// basic [Object] that points to a value in a map stored elsewhere.
///
/// NOTE: This is officially supported on Android and the Web only. Check
/// [supportedOnCurrentPlatform] before using.
class MappingAudioSource<T> extends IndexedAudioSource {
/// An identifier representing the [AudioSource] to be created.
final T identifier;

/// A function that creates the [AudioSource] to be played.
///
/// If `null` is returned, a silent, instant sound will be used instead.
final Future<IndexedAudioSource?> Function(T identifier) createAudioSource;

MappingAudioSource(
this.identifier,
this.createAudioSource, {
dynamic tag,
Duration? duration,
}) : assert(supportedOnCurrentPlatform),
super(tag: tag, duration: duration);

Future<IndexedAudioSource?>? _audioSourceFuture;

@override
IndexedAudioSourceMessage _toMessage() => MappingAudioSourceMessage(
id: _id,
createAudioSourceMessage: () => (_audioSourceFuture ??=
createAudioSource(identifier).then((audioSource) =>
audioSource?._setup(_player!).then((_) => audioSource)))
.then((audioSource) => audioSource?._toMessage()),
);

static bool get supportedOnCurrentPlatform =>
JustAudioPlatform.instance.supportsMappingAudioSource;
}

/// An [AudioSource] representing a concatenation of multiple audio sources to
/// be played in succession. This can be used to create playlists. Playback
/// between items will be gapless on Android, iOS and macOS, while there will
Expand Down Expand Up @@ -2562,7 +2614,7 @@ class ClippingAudioSource extends IndexedAudioSource {
}

@override
AudioSourceMessage _toMessage() => ClippingAudioSourceMessage(
IndexedAudioSourceMessage _toMessage() => ClippingAudioSourceMessage(
id: _id,
child: child._toMessage() as UriAudioSourceMessage,
start: start,
Expand Down Expand Up @@ -2634,7 +2686,7 @@ abstract class StreamAudioSource extends IndexedAudioSource {
Future<StreamAudioResponse> request([int? start, int? end]);

@override
AudioSourceMessage _toMessage() => ProgressiveAudioSourceMessage(
IndexedAudioSourceMessage _toMessage() => ProgressiveAudioSourceMessage(
id: _id, uri: _uri.toString(), headers: null, tag: tag);
}

Expand Down
Loading